diff --git a/For Real Consulting.slnx b/For Real Consulting.slnx new file mode 100644 index 0000000..2fa5cd2 --- /dev/null +++ b/For Real Consulting.slnx @@ -0,0 +1,3 @@ + + + diff --git a/For Real Consulting/For Real Consulting/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs b/For Real Consulting/For Real Consulting/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..9b7b23c --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs @@ -0,0 +1,153 @@ +using System.Security.Claims; +using System.Text.Json; +using For_Real_Consulting.Components.Account.Pages; +using For_Real_Consulting.Components.Account.Pages.Manage; +using For_Real_Consulting.Data; +using Microsoft.AspNetCore.Antiforgery; +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; + +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 signInManager, + [FromForm] string provider, + [FromForm] string returnUrl) => + { + IEnumerable> 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 signInManager, + [FromForm] string returnUrl) => + { + await signInManager.SignOutAsync(); + return TypedResults.LocalRedirect($"~/{returnUrl}"); + }); + + accountGroup.MapPost("/PasskeyCreationOptions", async ( + HttpContext context, + [FromServices] UserManager userManager, + [FromServices] SignInManager signInManager, + [FromServices] IAntiforgery antiforgery) => + { + await antiforgery.ValidateRequestAsync(context); + + 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); + var userName = await userManager.GetUserNameAsync(user) ?? "User"; + var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(new() + { + Id = userId, + Name = userName, + DisplayName = userName + }); + return TypedResults.Content(optionsJson, contentType: "application/json"); + }); + + accountGroup.MapPost("/PasskeyRequestOptions", async ( + HttpContext context, + [FromServices] UserManager userManager, + [FromServices] SignInManager signInManager, + [FromServices] IAntiforgery antiforgery, + [FromQuery] string? username) => + { + await antiforgery.ValidateRequestAsync(context); + + var user = string.IsNullOrEmpty(username) ? null : await userManager.FindByNameAsync(username); + var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user); + return TypedResults.Content(optionsJson, contentType: "application/json"); + }); + + var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization(); + + manageGroup.MapPost("/LinkExternalLogin", async ( + HttpContext context, + [FromServices] SignInManager 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(); + var downloadLogger = loggerFactory.CreateLogger("DownloadPersonalData"); + + manageGroup.MapPost("/DownloadPersonalData", async ( + HttpContext context, + [FromServices] UserManager 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(); + 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; + } + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/IdentityNoOpEmailSender.cs b/For Real Consulting/For Real Consulting/Components/Account/IdentityNoOpEmailSender.cs new file mode 100644 index 0000000..6db6e5c --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/IdentityNoOpEmailSender.cs @@ -0,0 +1,21 @@ +using For_Real_Consulting.Data; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; + +namespace For_Real_Consulting.Components.Account +{ + // Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation. + internal sealed class IdentityNoOpEmailSender : IEmailSender + { + 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 clicking here."); + + public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) => + emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by clicking here."); + + public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) => + emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}"); + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/IdentityRedirectManager.cs b/For Real Consulting/For Real Consulting/Components/Account/IdentityRedirectManager.cs new file mode 100644 index 0000000..0c081e1 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/IdentityRedirectManager.cs @@ -0,0 +1,55 @@ +using For_Real_Consulting.Data; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Identity; + +namespace For_Real_Consulting.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), + }; + + public void RedirectTo(string? uri) + { + uri ??= ""; + + // Prevent open redirects. + if (!Uri.IsWellFormedUriString(uri, UriKind.Relative)) + { + uri = navigationManager.ToBaseRelativePath(uri); + } + + navigationManager.NavigateTo(uri); + } + + public void RedirectTo(string uri, Dictionary queryParameters) + { + var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path); + var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters); + RedirectTo(newUri); + } + + 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); + + public void RedirectToCurrentPage() => RedirectTo(CurrentPath); + + public void RedirectToCurrentPageWithStatus(string message, HttpContext context) + => RedirectToWithStatus(CurrentPath, message, context); + + public void RedirectToInvalidUser(UserManager userManager, HttpContext context) + => RedirectToWithStatus("Account/InvalidUser", $"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context); + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs b/For Real Consulting/For Real Consulting/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs new file mode 100644 index 0000000..64b1307 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs @@ -0,0 +1,48 @@ +using System.Security.Claims; +using For_Real_Consulting.Data; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Server; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; + +namespace For_Real_Consulting.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 options) + : RevalidatingServerAuthenticationStateProvider(loggerFactory) + { + protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30); + + protected override async Task ValidateAuthenticationStateAsync( + AuthenticationState authenticationState, CancellationToken cancellationToken) + { + // Get the user manager from a new scope to ensure it fetches fresh data + await using var scope = scopeFactory.CreateAsyncScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + return await ValidateSecurityStampAsync(userManager, authenticationState.User); + } + + private async Task ValidateSecurityStampAsync(UserManager userManager, ClaimsPrincipal principal) + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return false; + } + else if (!userManager.SupportsUserSecurityStamp) + { + return true; + } + else + { + var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType); + var userStamp = await userManager.GetSecurityStampAsync(user); + return principalStamp == userStamp; + } + } + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/AccessDenied.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/AccessDenied.razor new file mode 100644 index 0000000..905dec3 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/AccessDenied.razor @@ -0,0 +1,8 @@ +@page "/Account/AccessDenied" + +Access denied + +
+

Access denied

+

You do not have access to this resource.

+
diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/ConfirmEmail.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/ConfirmEmail.razor new file mode 100644 index 0000000..2683949 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/ConfirmEmail.razor @@ -0,0 +1,49 @@ +@page "/Account/ConfirmEmail" + +@using System.Text +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject IdentityRedirectManager RedirectManager + +Confirm email + +

Confirm email

+ + +@code { + private string? statusMessage; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromQuery] + private string? UserId { get; set; } + + [SupplyParameterFromQuery] + private string? Code { get; set; } + + protected override async Task OnInitializedAsync() + { + if (UserId is null || Code is null) + { + RedirectManager.RedirectTo(""); + return; + } + + var user = await UserManager.FindByIdAsync(UserId); + if (user is null) + { + HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; + statusMessage = $"Error loading user with ID {UserId}"; + } + else + { + var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code)); + var result = await UserManager.ConfirmEmailAsync(user, code); + statusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email."; + } + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/ConfirmEmailChange.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/ConfirmEmailChange.razor new file mode 100644 index 0000000..1d2ee67 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/ConfirmEmailChange.razor @@ -0,0 +1,69 @@ +@page "/Account/ConfirmEmailChange" + +@using System.Text +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityRedirectManager RedirectManager + +Confirm email change + +

Confirm email change

+ + + +@code { + private string? message; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromQuery] + private string? UserId { get; set; } + + [SupplyParameterFromQuery] + private string? Email { get; set; } + + [SupplyParameterFromQuery] + private string? Code { get; set; } + + protected override async Task OnInitializedAsync() + { + if (UserId is null || Email is null || Code is null) + { + RedirectManager.RedirectToWithStatus( + "Account/Login", "Error: Invalid email change confirmation link.", HttpContext); + return; + } + + var user = await UserManager.FindByIdAsync(UserId); + if (user is null) + { + message = "Unable to find user with Id '{userId}'"; + return; + } + + var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code)); + var result = await UserManager.ChangeEmailAsync(user, Email, code); + if (!result.Succeeded) + { + message = "Error changing email."; + return; + } + + // In our UI email and user name are one and the same, so when we update the email + // we need to update the user name. + var setUserNameResult = await UserManager.SetUserNameAsync(user, Email); + if (!setUserNameResult.Succeeded) + { + message = "Error changing user name."; + return; + } + + await SignInManager.RefreshSignInAsync(user); + message = "Thank you for confirming your email change."; + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/ExternalLogin.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/ExternalLogin.razor new file mode 100644 index 0000000..e3903d3 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/ExternalLogin.razor @@ -0,0 +1,217 @@ +@page "/Account/ExternalLogin" + +@using System.ComponentModel.DataAnnotations +@using System.Security.Claims +@using System.Text +@using System.Text.Encodings.Web +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using For_Real_Consulting.Data + +@inject SignInManager SignInManager +@inject UserManager UserManager +@inject IUserStore UserStore +@inject IEmailSender EmailSender +@inject NavigationManager NavigationManager +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Register + + +

Register

+

Associate your @ProviderDisplayName account.

+
+ +
+ You've successfully authenticated with @ProviderDisplayName. + Please enter an email address for this site below and click the Register button to finish + logging in. +
+ +
+
+ + + +
+ + + +
+ +
+
+
+ +@code { + public const string LoginCallbackAction = "LoginCallback"; + + private string? message; + private ExternalLoginInfo? externalLoginInfo; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = default!; + + [SupplyParameterFromQuery] + private string? RemoteError { get; set; } + + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } + + [SupplyParameterFromQuery] + private string? Action { get; set; } + + private string? ProviderDisplayName => externalLoginInfo?.ProviderDisplayName; + + protected override async Task OnInitializedAsync() + { + Input ??= new(); + + if (RemoteError is not null) + { + RedirectManager.RedirectToWithStatus("Account/Login", $"Error from external provider: {RemoteError}", HttpContext); + return; + } + + var info = await SignInManager.GetExternalLoginInfoAsync(); + if (info is null) + { + RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information.", HttpContext); + return; + } + + externalLoginInfo = info; + + if (HttpMethods.IsGet(HttpContext.Request.Method)) + { + if (Action == LoginCallbackAction) + { + await OnLoginCallbackAsync(); + return; + } + + // We should only reach this page via the login callback, so redirect back to + // the login page if we get here some other way. + RedirectManager.RedirectTo("Account/Login"); + } + } + + private async Task OnLoginCallbackAsync() + { + if (externalLoginInfo is null) + { + RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information.", HttpContext); + return; + } + + // Sign in the user with this external login provider if the user already has a login. + var result = await SignInManager.ExternalLoginSignInAsync( + externalLoginInfo.LoginProvider, + externalLoginInfo.ProviderKey, + isPersistent: false, + bypassTwoFactor: true); + + if (result.Succeeded) + { + Logger.LogInformation( + "{Name} logged in with {LoginProvider} provider.", + externalLoginInfo.Principal.Identity?.Name, + externalLoginInfo.LoginProvider); + RedirectManager.RedirectTo(ReturnUrl); + return; + } + else if (result.IsLockedOut) + { + RedirectManager.RedirectTo("Account/Lockout"); + return; + } + + // If the user does not have an account, then ask the user to create an account. + if (externalLoginInfo.Principal.HasClaim(c => c.Type == ClaimTypes.Email)) + { + Input.Email = externalLoginInfo.Principal.FindFirstValue(ClaimTypes.Email) ?? ""; + } + } + + private async Task OnValidSubmitAsync() + { + if (externalLoginInfo is null) + { + RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information during confirmation.", HttpContext); + return; + } + + var emailStore = GetEmailStore(); + var user = CreateUser(); + + await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None); + await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None); + + var result = await UserManager.CreateAsync(user); + if (result.Succeeded) + { + result = await UserManager.AddLoginAsync(user, externalLoginInfo); + if (result.Succeeded) + { + Logger.LogInformation("User created an account using {Name} provider.", externalLoginInfo.LoginProvider); + + var userId = await UserManager.GetUserIdAsync(user); + var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + + var callbackUrl = NavigationManager.GetUriWithQueryParameters( + NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri, + new Dictionary { ["userId"] = userId, ["code"] = code }); + await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); + + // If account confirmation is required, we need to show the link if we don't have a real email sender + if (UserManager.Options.SignIn.RequireConfirmedAccount) + { + RedirectManager.RedirectTo("Account/RegisterConfirmation", new() { ["email"] = Input.Email }); + } + else + { + await SignInManager.SignInAsync(user, isPersistent: false, externalLoginInfo.LoginProvider); + RedirectManager.RedirectTo(ReturnUrl); + } + } + } + else + { + message = $"Error: {string.Join(",", result.Errors.Select(error => error.Description))}"; + } + } + + private ApplicationUser CreateUser() + { + try + { + return Activator.CreateInstance(); + } + catch + { + throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " + + $"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor"); + } + } + + private IUserEmailStore GetEmailStore() + { + if (!UserManager.SupportsUserEmail) + { + throw new NotSupportedException("The default UI requires a user store with email support."); + } + return (IUserEmailStore)UserStore; + } + + private sealed class InputModel + { + [Required] + [EmailAddress] + public string Email { get; set; } = ""; + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/ForgotPassword.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/ForgotPassword.razor new file mode 100644 index 0000000..5042b7f --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/ForgotPassword.razor @@ -0,0 +1,74 @@ +@page "/Account/ForgotPassword" + +@using System.ComponentModel.DataAnnotations +@using System.Text +@using System.Text.Encodings.Web +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject IEmailSender EmailSender +@inject NavigationManager NavigationManager +@inject IdentityRedirectManager RedirectManager + +Forgot your password? + +

Forgot your password?

+

Enter your email.

+
+
+
+ + + + +
+ + + +
+ +
+
+
+ +@code { + [SupplyParameterFromForm] + private InputModel Input { get; set; } = default!; + + protected override void OnInitialized() + { + Input ??= new(); + } + + private async Task OnValidSubmitAsync() + { + var user = await UserManager.FindByEmailAsync(Input.Email); + if (user is null || !(await UserManager.IsEmailConfirmedAsync(user))) + { + // Don't reveal that the user does not exist or is not confirmed + RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation"); + return; + } + + // For more information on how to enable account confirmation and password reset please + // visit https://go.microsoft.com/fwlink/?LinkID=532713 + var code = await UserManager.GeneratePasswordResetTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = NavigationManager.GetUriWithQueryParameters( + NavigationManager.ToAbsoluteUri("Account/ResetPassword").AbsoluteUri, + new Dictionary { ["code"] = code }); + + await EmailSender.SendPasswordResetLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); + + RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation"); + } + + private sealed class InputModel + { + [Required] + [EmailAddress] + public string Email { get; set; } = ""; + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/ForgotPasswordConfirmation.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/ForgotPasswordConfirmation.razor new file mode 100644 index 0000000..a771a3a --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/ForgotPasswordConfirmation.razor @@ -0,0 +1,8 @@ +@page "/Account/ForgotPasswordConfirmation" + +Forgot password confirmation + +

Forgot password confirmation

+

+ Please check your email to reset your password. +

diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/InvalidPasswordReset.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/InvalidPasswordReset.razor new file mode 100644 index 0000000..561b651 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/InvalidPasswordReset.razor @@ -0,0 +1,8 @@ +@page "/Account/InvalidPasswordReset" + +Invalid password reset + +

Invalid password reset

+

+ The password reset link is invalid. +

diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/InvalidUser.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/InvalidUser.razor new file mode 100644 index 0000000..e61fe5d --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/InvalidUser.razor @@ -0,0 +1,7 @@ +@page "/Account/InvalidUser" + +Invalid user + +

Invalid user

+ + diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Lockout.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Lockout.razor new file mode 100644 index 0000000..017e31d --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Lockout.razor @@ -0,0 +1,8 @@ +@page "/Account/Lockout" + +Locked out + +
+

Locked out

+ +
diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Login.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Login.razor new file mode 100644 index 0000000..3ba1cc9 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Login.razor @@ -0,0 +1,164 @@ +@page "/Account/Login" + +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Identity +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject ILogger Logger +@inject NavigationManager NavigationManager +@inject IdentityRedirectManager RedirectManager + +Log in + +

Log in

+
+
+
+ + + +

Use a local account to log in.

+
+ +
+ + + +
+
+ + + +
+
+ +
+
+ +
+
+
+ OR + Log in with a passkey +
+
+ +
+
+
+
+
+

Use another service to log in.

+
+ +
+
+
+ +@code { + private string? errorMessage; + private EditContext editContext = default!; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = default!; + + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } + + protected override async Task OnInitializedAsync() + { + Input ??= new(); + + editContext = new EditContext(Input); + + 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() + { + if (!string.IsNullOrEmpty(Input.Passkey?.Error)) + { + errorMessage = $"Error: {Input.Passkey.Error}"; + return; + } + + SignInResult result; + if (!string.IsNullOrEmpty(Input.Passkey?.CredentialJson)) + { + // When performing passkey sign-in, don't perform form validation. + result = await SignInManager.PasskeySignInAsync(Input.Passkey.CredentialJson); + } + else + { + // If doing a password sign-in, validate the form. + if (!editContext.Validate()) + { + return; + } + + // This doesn't count login failures towards account lockout + // To enable password failures to trigger account lockout, set lockoutOnFailure: true + 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; } + + public PasskeyInputModel? Passkey { get; set; } + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/LoginWith2fa.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/LoginWith2fa.razor new file mode 100644 index 0000000..392b0f7 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/LoginWith2fa.razor @@ -0,0 +1,103 @@ +@page "/Account/LoginWith2fa" + +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Identity +@using For_Real_Consulting.Data + +@inject SignInManager SignInManager +@inject UserManager UserManager +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Two-factor authentication + +

Two-factor authentication

+
+ +

Your login is protected with an authenticator app. Enter your authenticator code below.

+
+
+ + + + + +
+ + + +
+
+ +
+
+ +
+
+
+
+

+ Don't have access to your authenticator device? You can + log in with a recovery code. +

+ +@code { + private string? message; + private ApplicationUser user = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = default!; + + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } + + [SupplyParameterFromQuery] + private bool RememberMe { get; set; } + + protected override async Task OnInitializedAsync() + { + Input ??= new(); + + // Ensure the user has gone through the username & password screen first + user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? + throw new InvalidOperationException("Unable to load two-factor authentication user."); + } + + private async Task OnValidSubmitAsync() + { + var authenticatorCode = Input.TwoFactorCode!.Replace(" ", string.Empty).Replace("-", string.Empty); + var result = await SignInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, RememberMe, Input.RememberMachine); + var userId = await UserManager.GetUserIdAsync(user); + + if (result.Succeeded) + { + Logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId); + RedirectManager.RedirectTo(ReturnUrl); + } + else if (result.IsLockedOut) + { + Logger.LogWarning("User with ID '{UserId}' account locked out.", userId); + RedirectManager.RedirectTo("Account/Lockout"); + } + else + { + Logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", userId); + message = "Error: Invalid authenticator code."; + } + } + + private sealed class InputModel + { + [Required] + [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Text)] + [Display(Name = "Authenticator code")] + public string? TwoFactorCode { get; set; } + + [Display(Name = "Remember this machine")] + public bool RememberMachine { get; set; } + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/LoginWithRecoveryCode.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/LoginWithRecoveryCode.razor new file mode 100644 index 0000000..b8b959f --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/LoginWithRecoveryCode.razor @@ -0,0 +1,87 @@ +@page "/Account/LoginWithRecoveryCode" + +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Identity +@using For_Real_Consulting.Data + +@inject SignInManager SignInManager +@inject UserManager UserManager +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Recovery code verification + +

Recovery code verification

+
+ +

+ You have requested to log in with a recovery code. This login will not be remembered until you provide + an authenticator app code at log in or disable 2FA and log in again. +

+
+
+ + + +
+ + + +
+ +
+
+
+ +@code { + private string? message; + private ApplicationUser user = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = default!; + + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } + + protected override async Task OnInitializedAsync() + { + Input ??= new(); + + // Ensure the user has gone through the username & password screen first + user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? + throw new InvalidOperationException("Unable to load two-factor authentication user."); + } + + private async Task OnValidSubmitAsync() + { + var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty); + + var result = await SignInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode); + + var userId = await UserManager.GetUserIdAsync(user); + + if (result.Succeeded) + { + Logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", userId); + RedirectManager.RedirectTo(ReturnUrl); + } + else if (result.IsLockedOut) + { + Logger.LogWarning("User account locked out."); + RedirectManager.RedirectTo("Account/Lockout"); + } + else + { + Logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", userId); + message = "Error: Invalid recovery code entered."; + } + } + + private sealed class InputModel + { + [Required] + [DataType(DataType.Text)] + [Display(Name = "Recovery Code")] + public string RecoveryCode { get; set; } = ""; + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/ChangePassword.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/ChangePassword.razor new file mode 100644 index 0000000..ebb7431 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/ChangePassword.razor @@ -0,0 +1,109 @@ +@page "/Account/Manage/ChangePassword" + +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Identity +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Change password + +

Change password

+ +
+
+ + + +
+ + + +
+
+ + + +
+
+ + + +
+ +
+
+
+ +@code { + private string? message; + private ApplicationUser? user; + private bool hasPassword; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + Input ??= new(); + + user = await UserManager.GetUserAsync(HttpContext.User); + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + hasPassword = await UserManager.HasPasswordAsync(user); + if (!hasPassword) + { + RedirectManager.RedirectTo("Account/Manage/SetPassword"); + } + } + + private async Task OnValidSubmitAsync() + { + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + var changePasswordResult = await UserManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword); + if (!changePasswordResult.Succeeded) + { + message = $"Error: {string.Join(",", changePasswordResult.Errors.Select(error => error.Description))}"; + return; + } + + await SignInManager.RefreshSignInAsync(user); + Logger.LogInformation("User changed their password successfully."); + + RedirectManager.RedirectToCurrentPageWithStatus("Your password has been changed", HttpContext); + } + + private sealed class InputModel + { + [Required] + [DataType(DataType.Password)] + [Display(Name = "Current password")] + public string OldPassword { get; set; } = ""; + + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "New password")] + public string NewPassword { get; set; } = ""; + + [DataType(DataType.Password)] + [Display(Name = "Confirm new password")] + [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } = ""; + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/DeletePersonalData.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/DeletePersonalData.razor new file mode 100644 index 0000000..b89225a --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/DeletePersonalData.razor @@ -0,0 +1,97 @@ +@page "/Account/Manage/DeletePersonalData" + +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Identity +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Delete Personal Data + + + +

Delete Personal Data

+ + + +
+ + + + @if (requirePassword) + { +
+ + + +
+ } + +
+
+ +@code { + private string? message; + private ApplicationUser? user; + private bool requirePassword; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + Input ??= new(); + + user = await UserManager.GetUserAsync(HttpContext.User); + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + requirePassword = await UserManager.HasPasswordAsync(user); + } + + private async Task OnValidSubmitAsync() + { + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + 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; } = ""; + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/Disable2fa.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/Disable2fa.razor new file mode 100644 index 0000000..72879f1 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/Disable2fa.razor @@ -0,0 +1,74 @@ +@page "/Account/Manage/Disable2fa" + +@using Microsoft.AspNetCore.Identity +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Disable two-factor authentication (2FA) + + +

Disable two-factor authentication (2FA)

+ + + +
+
+ + + +
+ +@code { + private ApplicationUser? user; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + user = await UserManager.GetUserAsync(HttpContext.User); + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + if (HttpMethods.IsGet(HttpContext.Request.Method) && !await UserManager.GetTwoFactorEnabledAsync(user)) + { + throw new InvalidOperationException("Cannot disable 2FA for user as it's not currently enabled."); + } + } + + private async Task OnSubmitAsync() + { + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + var disable2faResult = await UserManager.SetTwoFactorEnabledAsync(user, false); + if (!disable2faResult.Succeeded) + { + throw new InvalidOperationException("Unexpected error occurred disabling 2FA."); + } + + var userId = await UserManager.GetUserIdAsync(user); + Logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", userId); + RedirectManager.RedirectToWithStatus( + "Account/Manage/TwoFactorAuthentication", + "2fa has been disabled. You can reenable 2fa when you setup an authenticator app", + HttpContext); + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/Email.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/Email.razor new file mode 100644 index 0000000..6355414 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/Email.razor @@ -0,0 +1,143 @@ +@page "/Account/Manage/Email" + +@using System.ComponentModel.DataAnnotations +@using System.Text +@using System.Text.Encodings.Web +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject IEmailSender EmailSender +@inject NavigationManager NavigationManager +@inject IdentityRedirectManager RedirectManager + +Manage email + +

Manage email

+ + +
+
+
+ + + + + + @if (isEmailConfirmed) + { +
+ +
+ +
+ +
+ } + else + { +
+ + + +
+ } +
+ + + +
+ +
+
+
+ +@code { + private string? message; + private ApplicationUser? user; + private string? email; + private bool isEmailConfirmed; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm(FormName = "change-email")] + private InputModel Input { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + Input ??= new(); + + user = await UserManager.GetUserAsync(HttpContext.User); + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + 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; + } + + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + 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 { ["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; + } + + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + 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 { ["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; } + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/EnableAuthenticator.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/EnableAuthenticator.razor new file mode 100644 index 0000000..027c52b --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/EnableAuthenticator.razor @@ -0,0 +1,184 @@ +@page "/Account/Manage/EnableAuthenticator" + +@using System.ComponentModel.DataAnnotations +@using System.Globalization +@using System.Text +@using System.Text.Encodings.Web +@using Microsoft.AspNetCore.Identity +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject UrlEncoder UrlEncoder +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Configure authenticator app + +@if (recoveryCodes is not null) +{ + +} +else +{ + +

Configure authenticator app

+
+

To use an authenticator app go through the following steps:

+
    +
  1. +

    + Download a two-factor authenticator app like Microsoft Authenticator for + Android and + iOS or + Google Authenticator for + Android and + iOS. +

    +
  2. +
  3. +

    Scan the QR Code or enter this key @sharedKey into your two factor authenticator app. Spaces and casing do not matter.

    + +
    +
    +
  4. +
  5. +

    + 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. +

    +
    +
    + + +
    + + + +
    + + +
    +
    +
    +
  6. +
+
+} + +@code { + private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6"; + + private string? message; + private ApplicationUser? user; + private string? sharedKey; + private string? authenticatorUri; + private IEnumerable? recoveryCodes; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + Input ??= new(); + + user = await UserManager.GetUserAsync(HttpContext.User); + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + await LoadSharedKeyAndQrCodeUriAsync(user); + } + + private async Task OnValidSubmitAsync() + { + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + // Strip spaces and hyphens + var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty); + + var is2faTokenValid = await UserManager.VerifyTwoFactorTokenAsync( + user, UserManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode); + + if (!is2faTokenValid) + { + message = "Error: Verification code is invalid."; + return; + } + + await UserManager.SetTwoFactorEnabledAsync(user, true); + var userId = await UserManager.GetUserIdAsync(user); + Logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", userId); + + message = "Your authenticator app has been verified."; + + if (await UserManager.CountRecoveryCodesAsync(user) == 0) + { + recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); + } + else + { + RedirectManager.RedirectToWithStatus("Account/Manage/TwoFactorAuthentication", message, HttpContext); + } + } + + private async ValueTask LoadSharedKeyAndQrCodeUriAsync(ApplicationUser user) + { + // Load the authenticator key & QR code URI to display on the form + var unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user); + if (string.IsNullOrEmpty(unformattedKey)) + { + await UserManager.ResetAuthenticatorKeyAsync(user); + unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user); + } + + sharedKey = FormatKey(unformattedKey!); + + var email = await UserManager.GetEmailAsync(user); + authenticatorUri = GenerateQrCodeUri(email!, unformattedKey!); + } + + private string FormatKey(string unformattedKey) + { + var result = new StringBuilder(); + int currentPosition = 0; + while (currentPosition + 4 < unformattedKey.Length) + { + result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' '); + currentPosition += 4; + } + if (currentPosition < unformattedKey.Length) + { + result.Append(unformattedKey.AsSpan(currentPosition)); + } + + return result.ToString().ToLowerInvariant(); + } + + private string GenerateQrCodeUri(string email, string unformattedKey) + { + return string.Format( + CultureInfo.InvariantCulture, + AuthenticatorUriFormat, + UrlEncoder.Encode("Microsoft.AspNetCore.Identity.UI"), + UrlEncoder.Encode(email), + unformattedKey); + } + + private sealed class InputModel + { + [Required] + [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Text)] + [Display(Name = "Verification Code")] + public string Code { get; set; } = ""; + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/ExternalLogins.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/ExternalLogins.razor new file mode 100644 index 0000000..7d0e8e6 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/ExternalLogins.razor @@ -0,0 +1,162 @@ +@page "/Account/Manage/ExternalLogins" + +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Identity +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IUserStore UserStore +@inject IdentityRedirectManager RedirectManager + +Manage your external logins + + +@if (currentLogins?.Count > 0) +{ +

Registered Logins

+ + + @foreach (var login in currentLogins) + { + + + + + } + +
@login.ProviderDisplayName + @if (showRemoveButton) + { +
+ +
+ + + +
+ + } + else + { + @:   + } +
+} +@if (otherLogins?.Count > 0) +{ +

Add another service to log in.

+
+
+ +
+

+ @foreach (var provider in otherLogins) + { + + } +

+
+ +} + +@code { + public const string LinkLoginCallbackAction = "LinkLoginCallback"; + + private ApplicationUser? user; + private IList? currentLogins; + private IList? 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 UserManager.GetUserAsync(HttpContext.User); + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + 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 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() + { + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + var result = await UserManager.RemoveLoginAsync(user, LoginProvider!, ProviderKey!); + if (!result.Succeeded) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: The external login was not removed.", HttpContext); + } + else + { + await SignInManager.RefreshSignInAsync(user); + RedirectManager.RedirectToCurrentPageWithStatus("The external login was removed.", HttpContext); + } + } + + private async Task OnGetLinkLoginCallbackAsync() + { + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + 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); + return; + } + + var result = await UserManager.AddLoginAsync(user, info); + if (result.Succeeded) + { + // Clear the existing external cookie to ensure a clean login process + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + + RedirectManager.RedirectToCurrentPageWithStatus("The external login was added.", HttpContext); + } + else + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: The external login was not added. External logins can only be associated with one account.", HttpContext); + } + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor new file mode 100644 index 0000000..66e9ca7 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor @@ -0,0 +1,78 @@ +@page "/Account/Manage/GenerateRecoveryCodes" + +@using Microsoft.AspNetCore.Identity +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Generate two-factor authentication (2FA) recovery codes + +@if (recoveryCodes is not null) +{ + +} +else +{ +

Generate two-factor authentication (2FA) recovery codes

+ +
+
+ + + +
+} + +@code { + private string? message; + private ApplicationUser? user; + private IEnumerable? recoveryCodes; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + user = await UserManager.GetUserAsync(HttpContext.User); + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + var isTwoFactorEnabled = await UserManager.GetTwoFactorEnabledAsync(user); + if (!isTwoFactorEnabled) + { + throw new InvalidOperationException("Cannot generate recovery codes for user because they do not have 2FA enabled."); + } + } + + private async Task OnSubmitAsync() + { + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + var userId = await UserManager.GetUserIdAsync(user); + recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); + message = "You have generated new recovery codes."; + + Logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId); + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/Index.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/Index.razor new file mode 100644 index 0000000..2c57523 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/Index.razor @@ -0,0 +1,91 @@ +@page "/Account/Manage" + +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Identity +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityRedirectManager RedirectManager + +Profile + +

Profile

+ + +
+
+ + + +
+ + +
+
+ + + +
+ +
+
+
+ +@code { + private ApplicationUser? user; + private string? username; + private string? phoneNumber; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + Input ??= new(); + + user = await UserManager.GetUserAsync(HttpContext.User); + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + username = await UserManager.GetUserNameAsync(user); + phoneNumber = await UserManager.GetPhoneNumberAsync(user); + + Input.PhoneNumber ??= phoneNumber; + } + + private async Task OnValidSubmitAsync() + { + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + if (Input.PhoneNumber != phoneNumber) + { + var setPhoneResult = await UserManager.SetPhoneNumberAsync(user, Input.PhoneNumber); + if (!setPhoneResult.Succeeded) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: Failed to set phone number.", HttpContext); + return; + } + } + + 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; } + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/Passkeys.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/Passkeys.razor new file mode 100644 index 0000000..887aa59 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/Passkeys.razor @@ -0,0 +1,182 @@ +@page "/Account/Manage/Passkeys" + +@using For_Real_Consulting.Data +@using Microsoft.AspNetCore.Identity +@using System.ComponentModel.DataAnnotations +@using System.Buffers.Text + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityRedirectManager RedirectManager + +Manage your passkeys + +

Manage your passkeys

+ + + +@if (currentPasskeys is { Count: > 0 }) +{ + + + @foreach (var passkey in currentPasskeys) + { + + + + + } + +
@(passkey.Name ?? "Unnamed passkey") + @{ + var credentialId = Base64Url.EncodeToString(passkey.CredentialId); + } +
+ +
+ + + +
+ +
+} +else +{ +

No passkeys are registered.

+} + +
+ + @if (currentPasskeys is { Count: >= MaxPasskeyCount }) + { +

You have reached the maximum number of allowed passkeys. Please delete one before adding a new one.

+ } + else + { + Add a new passkey + } + + + +@code { + private const int MaxPasskeyCount = 100; + + private ApplicationUser? user; + private IList? currentPasskeys; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private string? Action { get; set; } + + [SupplyParameterFromForm] + private string? CredentialId { get; set; } + + [SupplyParameterFromForm(FormName = "add-passkey")] + private PasskeyInputModel Input { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + Input ??= new(); + + user = await UserManager.GetUserAsync(HttpContext.User); + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + currentPasskeys = await UserManager.GetPasskeysAsync(user); + } + + private async Task AddPasskey() + { + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + if (!string.IsNullOrEmpty(Input.Error)) + { + RedirectManager.RedirectToCurrentPageWithStatus($"Error: {Input.Error}", HttpContext); + return; + } + + if (string.IsNullOrEmpty(Input.CredentialJson)) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: The browser did not provide a passkey.", HttpContext); + return; + } + + if (currentPasskeys!.Count >= MaxPasskeyCount) + { + RedirectManager.RedirectToCurrentPageWithStatus($"Error: You have reached the maximum number of allowed passkeys.", HttpContext); + return; + } + + var attestationResult = await SignInManager.PerformPasskeyAttestationAsync(Input.CredentialJson); + if (!attestationResult.Succeeded) + { + RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add the passkey: {attestationResult.Failure.Message}", HttpContext); + return; + } + + var addPasskeyResult = await UserManager.AddOrUpdatePasskeyAsync(user, attestationResult.Passkey); + if (!addPasskeyResult.Succeeded) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: The passkey could not be added to your account.", HttpContext); + return; + } + + // Immediately prompt the user to enter a name for the credential + var credentialIdBase64Url = Base64Url.EncodeToString(attestationResult.Passkey.CredentialId); + RedirectManager.RedirectTo($"Account/Manage/RenamePasskey/{credentialIdBase64Url}"); + } + + private async Task UpdatePasskey() + { + switch (Action) + { + case "rename": + RedirectManager.RedirectTo($"Account/Manage/RenamePasskey/{CredentialId}"); + break; + case "delete": + await DeletePasskey(); + break; + default: + RedirectManager.RedirectToCurrentPageWithStatus($"Error: Unknown action '{Action}'.", HttpContext); + break; + } + } + + private async Task DeletePasskey() + { + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + byte[] credentialId; + try + { + credentialId = Base64Url.DecodeFromChars(CredentialId); + } + catch (FormatException) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: The specified passkey ID had an invalid format.", HttpContext); + return; + } + + var result = await UserManager.RemovePasskeyAsync(user, credentialId); + if (!result.Succeeded) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: The passkey could not be deleted.", HttpContext); + return; + } + + RedirectManager.RedirectToCurrentPageWithStatus("Passkey deleted successfully.", HttpContext); + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/PersonalData.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/PersonalData.razor new file mode 100644 index 0000000..2afa672 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/PersonalData.razor @@ -0,0 +1,42 @@ +@page "/Account/Manage/PersonalData" + +@using Microsoft.AspNetCore.Identity +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject IdentityRedirectManager RedirectManager + +Personal Data + + +

Personal Data

+ +
+
+

Your account contains personal data that you have given us. This page allows you to download or delete that data.

+

+ Deleting this data will permanently remove your account, and this cannot be recovered. +

+
+ + + +

+ Delete +

+
+
+ +@code { + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + var user = await UserManager.GetUserAsync(HttpContext.User); + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + } + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/RenamePasskey.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/RenamePasskey.razor new file mode 100644 index 0000000..f1f4f26 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/RenamePasskey.razor @@ -0,0 +1,95 @@ +@page "/Account/Manage/RenamePasskey/{Id}" + +@using For_Real_Consulting.Data +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Identity +@using System.Buffers.Text + +@inject UserManager UserManager +@inject IdentityRedirectManager RedirectManager + + + + @if (passkey?.Name is { } name) + { +

Enter a new name for your "@name" passkey

+ } + else + { +

Enter a name for your passkey

+ } +
+ +
+ + + +
+
+ +
+
+ +@code { + private ApplicationUser? user; + private UserPasskeyInfo? passkey; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [Parameter] + public string? Id { get; set; } + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + Input ??= new(); + + user = (await UserManager.GetUserAsync(HttpContext.User))!; + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + byte[] credentialId; + try + { + credentialId = Base64Url.DecodeFromChars(Id); + } + catch (FormatException) + { + RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Error: The specified passkey ID had an invalid format.", HttpContext); + return; + } + + passkey = await UserManager.GetPasskeyAsync(user, credentialId); + if (passkey is null) + { + RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Error: The specified passkey could not be found.", HttpContext); + return; + } + } + + private async Task Rename() + { + passkey!.Name = Input.Name; + var result = await UserManager.AddOrUpdatePasskeyAsync(user!, passkey); + if (!result.Succeeded) + { + RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Error: The passkey could not be updated.", HttpContext); + return; + } + + RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Passkey updated successfully.", HttpContext); + } + + private sealed class InputModel + { + [Required] + [StringLength(200, ErrorMessage = "Passkey names must be no longer than {1} characters.")] + public string Name { get; set; } = ""; + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/ResetAuthenticator.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/ResetAuthenticator.razor new file mode 100644 index 0000000..d4687ac --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/ResetAuthenticator.razor @@ -0,0 +1,57 @@ +@page "/Account/Manage/ResetAuthenticator" + +@using Microsoft.AspNetCore.Identity +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Reset authenticator key + + +

Reset authenticator key

+ +
+
+ + + +
+ +@code { + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + private async Task OnSubmitAsync() + { + var user = await UserManager.GetUserAsync(HttpContext.User); + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + await UserManager.SetTwoFactorEnabledAsync(user, false); + await UserManager.ResetAuthenticatorKeyAsync(user); + var userId = await UserManager.GetUserIdAsync(user); + Logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", userId); + + await SignInManager.RefreshSignInAsync(user); + + RedirectManager.RedirectToWithStatus( + "Account/Manage/EnableAuthenticator", + "Your authenticator app key has been reset, you will need to configure your authenticator app using the new key.", + HttpContext); + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/SetPassword.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/SetPassword.razor new file mode 100644 index 0000000..7308717 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/SetPassword.razor @@ -0,0 +1,99 @@ +@page "/Account/Manage/SetPassword" + +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Identity +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityRedirectManager RedirectManager + +Set password + +

Set your password

+ +

+ You do not have a local username/password for this site. Add a local + account so you can log in without an external login. +

+
+
+ + + +
+ + + +
+
+ + + +
+ +
+
+
+ +@code { + private string? message; + private ApplicationUser? user; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + Input ??= new(); + + user = await UserManager.GetUserAsync(HttpContext.User); + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + var hasPassword = await UserManager.HasPasswordAsync(user); + if (hasPassword) + { + RedirectManager.RedirectTo("Account/Manage/ChangePassword"); + } + } + + private async Task OnValidSubmitAsync() + { + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + var addPasswordResult = await UserManager.AddPasswordAsync(user, Input.NewPassword!); + if (!addPasswordResult.Succeeded) + { + message = $"Error: {string.Join(",", addPasswordResult.Errors.Select(error => error.Description))}"; + return; + } + + await SignInManager.RefreshSignInAsync(user); + RedirectManager.RedirectToCurrentPageWithStatus("Your password has been set.", HttpContext); + } + + private sealed class InputModel + { + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "New password")] + public string? NewPassword { get; set; } + + [DataType(DataType.Password)] + [Display(Name = "Confirm new password")] + [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] + public string? ConfirmPassword { get; set; } + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/TwoFactorAuthentication.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/TwoFactorAuthentication.razor new file mode 100644 index 0000000..87897c0 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/TwoFactorAuthentication.razor @@ -0,0 +1,106 @@ +@page "/Account/Manage/TwoFactorAuthentication" + +@using Microsoft.AspNetCore.Http.Features +@using Microsoft.AspNetCore.Identity +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityRedirectManager RedirectManager + +Two-factor authentication (2FA) + + +

Two-factor authentication (2FA)

+@if (canTrack) +{ + if (is2faEnabled) + { + if (recoveryCodesLeft == 0) + { +
+ You have no recovery codes left. +

You must generate a new set of recovery codes before you can log in with a recovery code.

+
+ } + else if (recoveryCodesLeft == 1) + { +
+ You have 1 recovery code left. +

You can generate a new set of recovery codes.

+
+ } + else if (recoveryCodesLeft <= 3) + { +
+ You have @recoveryCodesLeft recovery codes left. +

You should generate a new set of recovery codes.

+
+ } + + if (isMachineRemembered) + { +
+ + + + } + + Disable 2FA + Reset recovery codes + } + +

Authenticator app

+ @if (!hasAuthenticator) + { + Add authenticator app + } + else + { + Set up authenticator app + Reset authenticator app + } +} +else +{ +
+ Privacy and cookie policy have not been accepted. +

You must accept the policy before you can enable two factor authentication.

+
+} + +@code { + private bool canTrack; + private bool hasAuthenticator; + private int recoveryCodesLeft; + private bool is2faEnabled; + private bool isMachineRemembered; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + var user = await UserManager.GetUserAsync(HttpContext.User); + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + canTrack = HttpContext.Features.Get()?.CanTrack ?? true; + hasAuthenticator = await UserManager.GetAuthenticatorKeyAsync(user) is not null; + is2faEnabled = await UserManager.GetTwoFactorEnabledAsync(user); + isMachineRemembered = await SignInManager.IsTwoFactorClientRememberedAsync(user); + recoveryCodesLeft = await UserManager.CountRecoveryCodesAsync(user); + } + + private async Task OnSubmitForgetBrowserAsync() + { + await SignInManager.ForgetTwoFactorClientAsync(); + + RedirectManager.RedirectToCurrentPageWithStatus( + "The current browser has been forgotten. When you login again from this browser you will be prompted for your 2fa code.", + HttpContext); + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/_Imports.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/_Imports.razor new file mode 100644 index 0000000..ada5bb0 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Manage/_Imports.razor @@ -0,0 +1,2 @@ +@layout ManageLayout +@attribute [Microsoft.AspNetCore.Authorization.Authorize] diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/Register.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/Register.razor new file mode 100644 index 0000000..b80cf74 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/Register.razor @@ -0,0 +1,152 @@ +@page "/Account/Register" + +@using System.ComponentModel.DataAnnotations +@using System.Text +@using System.Text.Encodings.Web +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject IUserStore UserStore +@inject SignInManager SignInManager +@inject IEmailSender EmailSender +@inject ILogger Logger +@inject NavigationManager NavigationManager +@inject IdentityRedirectManager RedirectManager + +Register + +

Register

+ +
+
+ + + +

Create a new account.

+
+ +
+ + + +
+
+ + + +
+
+ + + +
+ +
+
+
+
+

Use another service to register.

+
+ +
+
+
+ +@code { + private IEnumerable? identityErrors; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = default!; + + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } + + private string? Message => identityErrors is null ? null : $"Error: {string.Join(", ", identityErrors.Select(error => error.Description))}"; + + protected override void OnInitialized() + { + Input ??= new(); + } + + public async Task RegisterUser(EditContext editContext) + { + var user = CreateUser(); + + await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None); + var emailStore = GetEmailStore(); + await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None); + var result = await UserManager.CreateAsync(user, Input.Password); + + if (!result.Succeeded) + { + identityErrors = result.Errors; + return; + } + + Logger.LogInformation("User created a new account with password."); + + var userId = await UserManager.GetUserIdAsync(user); + var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = NavigationManager.GetUriWithQueryParameters( + NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri, + new Dictionary { ["userId"] = userId, ["code"] = code, ["returnUrl"] = ReturnUrl }); + + await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); + + if (UserManager.Options.SignIn.RequireConfirmedAccount) + { + RedirectManager.RedirectTo( + "Account/RegisterConfirmation", + new() { ["email"] = Input.Email, ["returnUrl"] = ReturnUrl }); + } + else + { + await SignInManager.SignInAsync(user, isPersistent: false); + RedirectManager.RedirectTo(ReturnUrl); + } + } + + private ApplicationUser CreateUser() + { + try + { + return Activator.CreateInstance(); + } + catch + { + throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " + + $"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor."); + } + } + + private IUserEmailStore GetEmailStore() + { + if (!UserManager.SupportsUserEmail) + { + throw new NotSupportedException("The default UI requires a user store with email support."); + } + return (IUserEmailStore)UserStore; + } + + private sealed class InputModel + { + [Required] + [EmailAddress] + [Display(Name = "Email")] + public string Email { get; set; } = ""; + + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "Password")] + public string Password { get; set; } = ""; + + [DataType(DataType.Password)] + [Display(Name = "Confirm password")] + [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } = ""; + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/RegisterConfirmation.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/RegisterConfirmation.razor new file mode 100644 index 0000000..38d636f --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/RegisterConfirmation.razor @@ -0,0 +1,69 @@ +@page "/Account/RegisterConfirmation" + +@using System.Text +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject IEmailSender EmailSender +@inject NavigationManager NavigationManager +@inject IdentityRedirectManager RedirectManager + +Register confirmation + +

Register confirmation

+ + + +@if (emailConfirmationLink is not null) +{ +

+ This app does not currently have a real email sender registered, see these docs for how to configure a real email sender. + Normally this would be emailed: Click here to confirm your account +

+} +else +{ +

Please check your email to confirm your account.

+} + +@code { + private string? emailConfirmationLink; + private string? statusMessage; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromQuery] + private string? Email { get; set; } + + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } + + protected override async Task OnInitializedAsync() + { + if (Email is null) + { + RedirectManager.RedirectTo(""); + return; + } + + var user = await UserManager.FindByEmailAsync(Email); + if (user is null) + { + HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; + statusMessage = "Error finding user for unspecified email"; + } + else if (EmailSender is IdentityNoOpEmailSender) + { + // Once you add a real email sender, you should remove this code that lets you confirm the account + var userId = await UserManager.GetUserIdAsync(user); + var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + emailConfirmationLink = NavigationManager.GetUriWithQueryParameters( + NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri, + new Dictionary { ["userId"] = userId, ["code"] = code, ["returnUrl"] = ReturnUrl }); + } + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/ResendEmailConfirmation.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/ResendEmailConfirmation.razor new file mode 100644 index 0000000..b4a067f --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/ResendEmailConfirmation.razor @@ -0,0 +1,73 @@ +@page "/Account/ResendEmailConfirmation" + +@using System.ComponentModel.DataAnnotations +@using System.Text +@using System.Text.Encodings.Web +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using For_Real_Consulting.Data + +@inject UserManager UserManager +@inject IEmailSender EmailSender +@inject NavigationManager NavigationManager +@inject IdentityRedirectManager RedirectManager + +Resend email confirmation + +

Resend email confirmation

+

Enter your email.

+
+ +
+
+ + + +
+ + + +
+ +
+
+
+ +@code { + private string? message; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = default!; + + protected override void OnInitialized() + { + Input ??= new(); + } + + private async Task OnValidSubmitAsync() + { + var user = await UserManager.FindByEmailAsync(Input.Email!); + if (user is null) + { + 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 { ["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; } = ""; + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/ResetPassword.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/ResetPassword.razor new file mode 100644 index 0000000..0014301 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/ResetPassword.razor @@ -0,0 +1,108 @@ +@page "/Account/ResetPassword" + +@using System.ComponentModel.DataAnnotations +@using System.Text +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using For_Real_Consulting.Data + +@inject IdentityRedirectManager RedirectManager +@inject UserManager UserManager + +Reset password + +

Reset password

+

Reset your password.

+
+
+
+ + + + + + +
+ + + +
+
+ + + +
+
+ + + +
+ +
+
+
+ +@code { + private IEnumerable? identityErrors; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = default!; + + [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() + { + Input ??= new(); + + if (Code is null) + { + RedirectManager.RedirectTo("Account/InvalidPasswordReset"); + return; + } + + 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"); + return; + } + + var result = await UserManager.ResetPasswordAsync(user, Input.Code, Input.Password); + if (result.Succeeded) + { + RedirectManager.RedirectTo("Account/ResetPasswordConfirmation"); + return; + } + + 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; } = ""; + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/ResetPasswordConfirmation.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/ResetPasswordConfirmation.razor new file mode 100644 index 0000000..247e96e --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/ResetPasswordConfirmation.razor @@ -0,0 +1,7 @@ +@page "/Account/ResetPasswordConfirmation" +Reset password confirmation + +

Reset password confirmation

+

+ Your password has been reset. Please click here to log in. +

diff --git a/For Real Consulting/For Real Consulting/Components/Account/Pages/_Imports.razor b/For Real Consulting/For Real Consulting/Components/Account/Pages/_Imports.razor new file mode 100644 index 0000000..10f9ece --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Pages/_Imports.razor @@ -0,0 +1,2 @@ +@using For_Real_Consulting.Components.Account.Shared +@attribute [ExcludeFromInteractiveRouting] diff --git a/For Real Consulting/For Real Consulting/Components/Account/PasskeyInputModel.cs b/For Real Consulting/For Real Consulting/Components/Account/PasskeyInputModel.cs new file mode 100644 index 0000000..d10afbf --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/PasskeyInputModel.cs @@ -0,0 +1,8 @@ +namespace For_Real_Consulting.Components.Account +{ + public class PasskeyInputModel + { + public string? CredentialJson { get; set; } + public string? Error { get; set; } + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/PasskeyOperation.cs b/For Real Consulting/For Real Consulting/Components/Account/PasskeyOperation.cs new file mode 100644 index 0000000..2f44aa9 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/PasskeyOperation.cs @@ -0,0 +1,8 @@ +namespace For_Real_Consulting.Components.Account +{ + public enum PasskeyOperation + { + Create = 0, + Request = 1, + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Shared/ExternalLoginPicker.razor b/For Real Consulting/For Real Consulting/Components/Account/Shared/ExternalLoginPicker.razor new file mode 100644 index 0000000..00a2147 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Shared/ExternalLoginPicker.razor @@ -0,0 +1,43 @@ +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Identity +@using For_Real_Consulting.Data + +@inject SignInManager SignInManager +@inject IdentityRedirectManager RedirectManager + +@if (externalLogins.Length == 0) +{ +
+

+ There are no external authentication services configured. See this article + about setting up this ASP.NET application to support logging in via external services. +

+
+} +else +{ +
+
+ + +

+ @foreach (var provider in externalLogins) + { + + } +

+
+
+} + +@code { + private AuthenticationScheme[] externalLogins = []; + + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } + + protected override async Task OnInitializedAsync() + { + externalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).ToArray(); + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Shared/ManageLayout.razor b/For Real Consulting/For Real Consulting/Components/Account/Shared/ManageLayout.razor new file mode 100644 index 0000000..730c9d4 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Shared/ManageLayout.razor @@ -0,0 +1,17 @@ +@inherits LayoutComponentBase +@layout For_Real_Consulting.Components.Layout.MainLayout + +

Manage your account

+ +
+

Change your account settings

+
+
+
+ +
+
+ @Body +
+
+
diff --git a/For Real Consulting/For Real Consulting/Components/Account/Shared/ManageNavMenu.razor b/For Real Consulting/For Real Consulting/Components/Account/Shared/ManageNavMenu.razor new file mode 100644 index 0000000..9b06130 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Shared/ManageNavMenu.razor @@ -0,0 +1,40 @@ +@using Microsoft.AspNetCore.Identity +@using For_Real_Consulting.Data + +@inject SignInManager SignInManager + + + +@code { + private bool hasExternalLogins; + + protected override async Task OnInitializedAsync() + { + hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any(); + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Shared/PasskeySubmit.razor b/For Real Consulting/For Real Consulting/Components/Account/Shared/PasskeySubmit.razor new file mode 100644 index 0000000..9db61ff --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Shared/PasskeySubmit.razor @@ -0,0 +1,40 @@ +@using Microsoft.AspNetCore.Antiforgery +@inject IServiceProvider Services + + + + + +@code { + private AntiforgeryTokenSet? tokens; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [Parameter] + [EditorRequired] + public PasskeyOperation Operation { get; set; } + + [Parameter] + [EditorRequired] + public string Name { get; set; } = default!; + + [Parameter] + public string? EmailName { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter(CaptureUnmatchedValues = true)] + public IDictionary? AdditionalAttributes { get; set; } + + protected override void OnInitialized() + { + tokens = Services.GetService()?.GetTokens(HttpContext); + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Shared/PasskeySubmit.razor.js b/For Real Consulting/For Real Consulting/Components/Account/Shared/PasskeySubmit.razor.js new file mode 100644 index 0000000..55a83bc --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Shared/PasskeySubmit.razor.js @@ -0,0 +1,123 @@ +const browserSupportsPasskeys = + typeof navigator.credentials !== 'undefined' && + typeof window.PublicKeyCredential !== 'undefined' && + typeof window.PublicKeyCredential.parseCreationOptionsFromJSON === 'function' && + typeof window.PublicKeyCredential.parseRequestOptionsFromJSON === 'function'; + +async function fetchWithErrorHandling(url, options = {}) { + const response = await fetch(url, { + credentials: 'include', + ...options + }); + if (!response.ok) { + const text = await response.text(); + console.error(text); + throw new Error(`The server responded with status ${response.status}.`); + } + return response; +} + +async function createCredential(headers, signal) { + const optionsResponse = await fetchWithErrorHandling('/Account/PasskeyCreationOptions', { + method: 'POST', + headers, + signal, + }); + const optionsJson = await optionsResponse.json(); + const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson); + return await navigator.credentials.create({ publicKey: options, signal }); +} + +async function requestCredential(email, mediation, headers, signal) { + const optionsResponse = await fetchWithErrorHandling(`/Account/PasskeyRequestOptions?username=${email}`, { + method: 'POST', + headers, + signal, + }); + const optionsJson = await optionsResponse.json(); + const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson); + return await navigator.credentials.get({ publicKey: options, mediation, signal }); +} + +customElements.define('passkey-submit', class extends HTMLElement { + static formAssociated = true; + + connectedCallback() { + this.internals = this.attachInternals(); + this.attrs = { + operation: this.getAttribute('operation'), + name: this.getAttribute('name'), + emailName: this.getAttribute('email-name'), + requestTokenName: this.getAttribute('request-token-name'), + requestTokenValue: this.getAttribute('request-token-value'), + }; + + this.internals.form.addEventListener('submit', (event) => { + if (event.submitter?.name === '__passkeySubmit') { + event.preventDefault(); + this.obtainAndSubmitCredential(); + } + }); + + this.tryAutofillPasskey(); + } + + disconnectedCallback() { + this.abortController?.abort(); + } + + async obtainCredential(useConditionalMediation, signal) { + if (!browserSupportsPasskeys) { + throw new Error('Some passkey features are missing. Please update your browser.'); + } + + const headers = { + [this.attrs.requestTokenName]: this.attrs.requestTokenValue, + }; + + if (this.attrs.operation === 'Create') { + return await createCredential(headers, signal); + } else if (this.attrs.operation === 'Request') { + const email = new FormData(this.internals.form).get(this.attrs.emailName); + const mediation = useConditionalMediation ? 'conditional' : undefined; + return await requestCredential(email, mediation, headers, signal); + } else { + throw new Error(`Unknown passkey operation '${this.attrs.operation}'.`); + } + } + + async obtainAndSubmitCredential(useConditionalMediation = false) { + this.abortController?.abort(); + this.abortController = new AbortController(); + const signal = this.abortController.signal; + const formData = new FormData(); + try { + const credential = await this.obtainCredential(useConditionalMediation, signal); + const credentialJson = JSON.stringify(credential); + formData.append(`${this.attrs.name}.CredentialJson`, credentialJson); + } catch (error) { + if (error.name === 'AbortError') { + // The user explicitly canceled the operation - return without error. + return; + } + console.error(error); + if (useConditionalMediation) { + // An error occurred during conditional mediation, which is not user-initiated. + // We log the error in the console but do not relay it to the user. + return; + } + const errorMessage = error.name === 'NotAllowedError' + ? 'No passkey was provided by the authenticator.' + : error.message; + formData.append(`${this.attrs.name}.Error`, errorMessage); + } + this.internals.setFormValue(formData); + this.internals.form.submit(); + } + + async tryAutofillPasskey() { + if (browserSupportsPasskeys && this.attrs.operation === 'Request' && await PublicKeyCredential.isConditionalMediationAvailable?.()) { + await this.obtainAndSubmitCredential(/* useConditionalMediation */ true); + } + } +}); diff --git a/For Real Consulting/For Real Consulting/Components/Account/Shared/ShowRecoveryCodes.razor b/For Real Consulting/For Real Consulting/Components/Account/Shared/ShowRecoveryCodes.razor new file mode 100644 index 0000000..aa92e11 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Shared/ShowRecoveryCodes.razor @@ -0,0 +1,28 @@ + +

Recovery codes

+ +
+
+ @foreach (var recoveryCode in RecoveryCodes) + { +
+ @recoveryCode +
+ } +
+
+ +@code { + [Parameter] + public string[] RecoveryCodes { get; set; } = []; + + [Parameter] + public string? StatusMessage { get; set; } +} diff --git a/For Real Consulting/For Real Consulting/Components/Account/Shared/StatusMessage.razor b/For Real Consulting/For Real Consulting/Components/Account/Shared/StatusMessage.razor new file mode 100644 index 0000000..12cd544 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Account/Shared/StatusMessage.razor @@ -0,0 +1,29 @@ +@if (!string.IsNullOrEmpty(DisplayMessage)) +{ + var statusMessageClass = DisplayMessage.StartsWith("Error") ? "danger" : "success"; + +} + +@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); + } + } +} diff --git a/For Real Consulting/For Real Consulting/Components/App.razor b/For Real Consulting/For Real Consulting/Components/App.razor new file mode 100644 index 0000000..afd2eb4 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/App.razor @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/For Real Consulting/For Real Consulting/Components/Layout/MainLayout.razor b/For Real Consulting/For Real Consulting/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..96fbbe6 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Layout/MainLayout.razor @@ -0,0 +1,9 @@ +@inherits LayoutComponentBase + +@Body + +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/For Real Consulting/For Real Consulting/Components/Layout/MainLayout.razor.css b/For Real Consulting/For Real Consulting/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000..60cec92 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Layout/MainLayout.razor.css @@ -0,0 +1,20 @@ +#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; + } diff --git a/For Real Consulting/For Real Consulting/Components/Layout/ReconnectModal.razor b/For Real Consulting/For Real Consulting/Components/Layout/ReconnectModal.razor new file mode 100644 index 0000000..e740b0c --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Layout/ReconnectModal.razor @@ -0,0 +1,31 @@ + + + +
+ +

+ Rejoining the server... +

+

+ Rejoin failed... trying again in seconds. +

+

+ Failed to rejoin.
Please retry or reload the page. +

+ +

+ The session has been paused by the server. +

+

+ Failed to resume the session.
Please retry or reload the page. +

+ +
+
diff --git a/For Real Consulting/For Real Consulting/Components/Layout/ReconnectModal.razor.css b/For Real Consulting/For Real Consulting/Components/Layout/ReconnectModal.razor.css new file mode 100644 index 0000000..3ad3773 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Layout/ReconnectModal.razor.css @@ -0,0 +1,157 @@ +.components-reconnect-first-attempt-visible, +.components-reconnect-repeated-attempt-visible, +.components-reconnect-failed-visible, +.components-pause-visible, +.components-resume-failed-visible, +.components-rejoining-animation { + display: none; +} + +#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible, +#components-reconnect-modal.components-reconnect-show .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-paused .components-pause-visible, +#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible, +#components-reconnect-modal.components-reconnect-retrying, +#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible, +#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-failed, +#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible { + display: block; +} + + +#components-reconnect-modal { + background-color: white; + width: 20rem; + margin: 20vh auto; + padding: 2rem; + border: 0; + border-radius: 0.5rem; + box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3); + opacity: 0; + transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete; + animation: components-reconnect-modal-fadeOutOpacity 0.5s both; + &[open] + +{ + animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s; + animation-fill-mode: both; +} + +} + +#components-reconnect-modal::backdrop { + background-color: rgba(0, 0, 0, 0.4); + animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out; + opacity: 1; +} + +@keyframes components-reconnect-modal-slideUp { + 0% { + transform: translateY(30px) scale(0.95); + } + + 100% { + transform: translateY(0); + } +} + +@keyframes components-reconnect-modal-fadeInOpacity { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes components-reconnect-modal-fadeOutOpacity { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +.components-reconnect-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +#components-reconnect-modal p { + margin: 0; + text-align: center; +} + +#components-reconnect-modal button { + border: 0; + background-color: #6b9ed2; + color: white; + padding: 4px 24px; + border-radius: 4px; +} + + #components-reconnect-modal button:hover { + background-color: #3b6ea2; + } + + #components-reconnect-modal button:active { + background-color: #6b9ed2; + } + +.components-rejoining-animation { + position: relative; + width: 80px; + height: 80px; +} + + .components-rejoining-animation div { + position: absolute; + border: 3px solid #0087ff; + opacity: 1; + border-radius: 50%; + animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite; + } + + .components-rejoining-animation div:nth-child(2) { + animation-delay: -0.5s; + } + +@keyframes components-rejoining-animation { + 0% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 4.9% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 5% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 1; + } + + 100% { + top: 0px; + left: 0px; + width: 80px; + height: 80px; + opacity: 0; + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Layout/ReconnectModal.razor.js b/For Real Consulting/For Real Consulting/Components/Layout/ReconnectModal.razor.js new file mode 100644 index 0000000..a44de78 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Layout/ReconnectModal.razor.js @@ -0,0 +1,63 @@ +// Set up event handlers +const reconnectModal = document.getElementById("components-reconnect-modal"); +reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged); + +const retryButton = document.getElementById("components-reconnect-button"); +retryButton.addEventListener("click", retry); + +const resumeButton = document.getElementById("components-resume-button"); +resumeButton.addEventListener("click", resume); + +function handleReconnectStateChanged(event) { + if (event.detail.state === "show") { + reconnectModal.showModal(); + } else if (event.detail.state === "hide") { + reconnectModal.close(); + } else if (event.detail.state === "failed") { + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } else if (event.detail.state === "rejected") { + location.reload(); + } +} + +async function retry() { + document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + + try { + // Reconnect will asynchronously return: + // - true to mean success + // - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID) + // - exception to mean we didn't reach the server (this can be sync or async) + const successful = await Blazor.reconnect(); + if (!successful) { + // We have been able to reach the server, but the circuit is no longer available. + // We'll reload the page so the user can continue using the app as quickly as possible. + const resumeSuccessful = await Blazor.resumeCircuit(); + if (!resumeSuccessful) { + location.reload(); + } else { + reconnectModal.close(); + } + } + } catch (err) { + // We got an exception, server is currently unavailable + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } +} + +async function resume() { + try { + const successful = await Blazor.resumeCircuit(); + if (!successful) { + location.reload(); + } + } catch { + reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed"); + } +} + +async function retryWhenDocumentBecomesVisible() { + if (document.visibilityState === "visible") { + await retry(); + } +} diff --git a/For Real Consulting/For Real Consulting/Components/Pages/Error.razor b/For Real Consulting/For Real Consulting/Components/Pages/Error.razor new file mode 100644 index 0000000..576cc2d --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@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; +} diff --git a/For Real Consulting/For Real Consulting/Components/Pages/Home.razor b/For Real Consulting/For Real Consulting/Components/Pages/Home.razor new file mode 100644 index 0000000..5325297 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Pages/Home.razor @@ -0,0 +1,196 @@ +@page "/" + +FRC - Finance Experts & Business Partners + +
+ + +
+
+

+ Finance Experts.
+ Business Partners.
+ Meet FRC. +

+

+ Wij brengen helderheid in cijfers, inzicht in cash en vertrouwen in beslissingen. + Zodat jij vandaag de juiste keuzes maakt voor duurzame groei morgen. +

+ Plan een kennismaking +
+
+ For Real Consulting +
+
+ +
+
+
+

Clarity in numbers

+

Heldere financiële inzichten die complexiteit omzetten in richting en focus.

+
+
+
👁
+

Visibility in cash

+

Volledig zicht op cashflow en financiële gezondheid, vandaag en morgen.

+
+
+
+

Confidence in decisions

+

Onderbouwde beslissingen met vertrouwen, gedreven door feiten.

+
+
+ +
+
+

Waarom partneren met FRC?

+

MEER DAN CIJFERS.
ÉCHT IMPACT.

+

Wij zijn jouw financiële sparringpartner en brengen meer dan rapporten.

+
    +
  • Strategisch partner op C-level
  • +
  • Proactief en ondememend
  • +
  • Diepgaande financiële expertise
  • +
  • Praktisch. hands-on en resultaatgericht
  • +
  • Onafhankelijk. transparant en betrouwbaar
  • +
  • Gepassioneerd om jouw groei te versnellen
  • +
+
+
+ For Real Consulting +
+

"Wij helpen ambitieuze ondernemers grip te krijgen op hun cijfers, met strategisch inzicht en een duidelijke focus op waardecreatie."

+
+
+
+ +
+

Een bewezen aanpak die waarde creëert

+
+
+
01
+

Analyse

+

Diepgaande analyse van jouw financiële situatie en uitdagingen.

+
+
+
02
+

Strategie

+

Samen ontwikkelen we een heldere strategie op maat van jouw ambities.

+
+
+
03
+

Implementatie

+

We ondersteunen bij de uitvoering en zorgen voor draagvlak.

+
+
+
04
+

Resultaat

+

Duurzame waarde en groei, meetbaar in cijfers en voelbaar in de organisatie.

+
+
+
+ +
+

Onze Expertise

+
+
Financial Strategy
+
Cash & Liquidity
+
Performance
+
Mergers & Acq.
+
Interim Finance
+
+
+ +
+
+ +

"For Real Consulting brengt helderheid waar we die het meest nodig hadden. Dankzij hun inzicht en betrokkenheid maken we vandaag betere beslissingen met vertrouwen."

+

CEO, Industrieel bedrijf

+
+
+ AVEC + COFANO + ZUIDERKEMPEN + DE PAEP + TRILEC +
+
+ +
+

Over Ons

+
+

KLAAR OM SAMEN HET VERSCHIL TE MAKEN?

+
+

Laten we kennismaken en ontdekken hoe wij jouw organisatie vooruithelpen.

+ Plan een kennismaking +
+
+ +
+
+
+
+

PETER SNIJKERS

+

Bestuurder & Finance Expert

+

0476 17 12 49

+

Peter.Snijkers@forrealconsulting.be

+
+
+
+
+
+

DYLAN TIJSBAERT

+

Bestuurder & Finance Expert

+

0498 16 94 49

+

Dylan.Tijsbaert@forrealconsulting.be

+
+
+
+
+ +
+ + +
+
diff --git a/For Real Consulting/For Real Consulting/Components/Pages/NotFound.razor b/For Real Consulting/For Real Consulting/Components/Pages/NotFound.razor new file mode 100644 index 0000000..917ada1 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Pages/NotFound.razor @@ -0,0 +1,5 @@ +@page "/not-found" +@layout MainLayout + +

Not Found

+

Sorry, the content you are looking for does not exist.

\ No newline at end of file diff --git a/For Real Consulting/For Real Consulting/Components/Routes.razor b/For Real Consulting/For Real Consulting/Components/Routes.razor new file mode 100644 index 0000000..c387474 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/Routes.razor @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/For Real Consulting/For Real Consulting/Components/_Imports.razor b/For Real Consulting/For Real Consulting/Components/_Imports.razor new file mode 100644 index 0000000..df76855 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Components/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using For_Real_Consulting +@using For_Real_Consulting.Components +@using For_Real_Consulting.Components.Layout diff --git a/For Real Consulting/For Real Consulting/Data/ApplicationDbContext.cs b/For Real Consulting/For Real Consulting/Data/ApplicationDbContext.cs new file mode 100644 index 0000000..155b587 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Data/ApplicationDbContext.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace For_Real_Consulting.Data +{ + public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext(options) + { + } +} diff --git a/For Real Consulting/For Real Consulting/Data/ApplicationUser.cs b/For Real Consulting/For Real Consulting/Data/ApplicationUser.cs new file mode 100644 index 0000000..cccafa0 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Data/ApplicationUser.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Identity; + +namespace For_Real_Consulting.Data +{ + // Add profile data for application users by adding properties to the ApplicationUser class + public class ApplicationUser : IdentityUser + { + } + +} diff --git a/For Real Consulting/For Real Consulting/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs b/For Real Consulting/For Real Consulting/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs new file mode 100644 index 0000000..40c543d --- /dev/null +++ b/For Real Consulting/For Real Consulting/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs @@ -0,0 +1,361 @@ +// +using System; +using For_Real_Consulting.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace For_Real_Consulting.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("00000000000000_CreateIdentitySchema")] + partial class CreateIdentitySchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("For_Real_Consulting.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.Property("CredentialId") + .HasMaxLength(1024) + .HasColumnType("varbinary(1024)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("CredentialId"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserPasskeys", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("For_Real_Consulting.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("For_Real_Consulting.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.HasOne("For_Real_Consulting.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => + { + b1.Property("IdentityUserPasskeyCredentialId") + .HasColumnType("varbinary(1024)"); + + b1.Property("AttestationObject") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b1.Property("ClientDataJson") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b1.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b1.Property("IsBackedUp") + .HasColumnType("bit"); + + b1.Property("IsBackupEligible") + .HasColumnType("bit"); + + b1.Property("IsUserVerified") + .HasColumnType("bit"); + + b1.Property("Name") + .HasColumnType("nvarchar(max)"); + + b1.Property("PublicKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b1.Property("SignCount") + .HasColumnType("bigint"); + + b1.PrimitiveCollection("Transports") + .HasColumnType("nvarchar(max)"); + + b1.HasKey("IdentityUserPasskeyCredentialId"); + + b1.ToTable("AspNetUserPasskeys"); + + b1.ToJson("Data"); + + b1.WithOwner() + .HasForeignKey("IdentityUserPasskeyCredentialId"); + }); + + b.Navigation("Data") + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("For_Real_Consulting.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("For_Real_Consulting.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/For Real Consulting/For Real Consulting/Data/Migrations/00000000000000_CreateIdentitySchema.cs b/For Real Consulting/For Real Consulting/Data/Migrations/00000000000000_CreateIdentitySchema.cs new file mode 100644 index 0000000..347e445 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Data/Migrations/00000000000000_CreateIdentitySchema.cs @@ -0,0 +1,251 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace For_Real_Consulting.Migrations +{ + /// + public partial class CreateIdentitySchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", 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(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", 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(type: "nvarchar(128)", maxLength: 128, nullable: false), + ProviderKey = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "nvarchar(450)", 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: "AspNetUserPasskeys", + columns: table => new + { + CredentialId = table.Column(type: "varbinary(1024)", maxLength: 1024, nullable: false), + UserId = table.Column(type: "nvarchar(450)", nullable: false), + Data = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserPasskeys", x => x.CredentialId); + table.ForeignKey( + name: "FK_AspNetUserPasskeys_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + RoleId = table.Column(type: "nvarchar(450)", 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(type: "nvarchar(450)", nullable: false), + LoginProvider = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Value = table.Column(type: "nvarchar(max)", 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, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserPasskeys_UserId", + table: "AspNetUserPasskeys", + 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, + filter: "[NormalizedUserName] IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserPasskeys"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/For Real Consulting/For Real Consulting/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/For Real Consulting/For Real Consulting/Data/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..e8a1e6a --- /dev/null +++ b/For Real Consulting/For Real Consulting/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,358 @@ +// +using System; +using For_Real_Consulting.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace For_Real_Consulting.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("For_Real_Consulting.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.Property("CredentialId") + .HasMaxLength(1024) + .HasColumnType("varbinary(1024)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("CredentialId"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserPasskeys", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("For_Real_Consulting.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("For_Real_Consulting.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.HasOne("For_Real_Consulting.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => + { + b1.Property("IdentityUserPasskeyCredentialId") + .HasColumnType("varbinary(1024)"); + + b1.Property("AttestationObject") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b1.Property("ClientDataJson") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b1.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b1.Property("IsBackedUp") + .HasColumnType("bit"); + + b1.Property("IsBackupEligible") + .HasColumnType("bit"); + + b1.Property("IsUserVerified") + .HasColumnType("bit"); + + b1.Property("Name") + .HasColumnType("nvarchar(max)"); + + b1.Property("PublicKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b1.Property("SignCount") + .HasColumnType("bigint"); + + b1.PrimitiveCollection("Transports") + .HasColumnType("nvarchar(max)"); + + b1.HasKey("IdentityUserPasskeyCredentialId"); + + b1.ToTable("AspNetUserPasskeys"); + + b1.ToJson("Data"); + + b1.WithOwner() + .HasForeignKey("IdentityUserPasskeyCredentialId"); + }); + + b.Navigation("Data") + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("For_Real_Consulting.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("For_Real_Consulting.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/For Real Consulting/For Real Consulting/For Real Consulting.csproj b/For Real Consulting/For Real Consulting/For Real Consulting.csproj new file mode 100644 index 0000000..5320cf9 --- /dev/null +++ b/For Real Consulting/For Real Consulting/For Real Consulting.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + aspnet-For_Real_Consulting-71c14b11-15d9-45ec-abb1-a46c13abc008 + For_Real_Consulting + $(AssemblyName.Replace(' ', '_')) + true + + + + + + + + + + + + + + + + diff --git a/For Real Consulting/For Real Consulting/Program.cs b/For Real Consulting/For Real Consulting/Program.cs new file mode 100644 index 0000000..7cf03f6 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Program.cs @@ -0,0 +1,79 @@ +using For_Real_Consulting.Components; +using For_Real_Consulting.Components.Account; +using For_Real_Consulting.Data; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; + +namespace For_Real_Consulting +{ + public class Program + { + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + // Add services to the container. + builder.Services.AddRazorComponents() + .AddInteractiveServerComponents() + .AddInteractiveWebAssemblyComponents() + .AddAuthenticationStateSerialization(); + + builder.Services.AddCascadingAuthenticationState(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + 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(options => + // options.UseSqlServer(connectionString)); + //builder.Services.AddDatabaseDeveloperPageExceptionFilter(); + + //builder.Services.AddIdentityCore(options => + // { + // options.SignIn.RequireConfirmedAccount = true; + // options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; + // }) + // .AddEntityFrameworkStores() + // .AddSignInManager() + // .AddDefaultTokenProviders(); + + builder.Services.AddSingleton, IdentityNoOpEmailSender>(); + + var app = builder.Build(); + + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment()) + { + app.UseWebAssemblyDebugging(); + app.UseMigrationsEndPoint(); + } + else + { + app.UseExceptionHandler("/Error"); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); + } + + app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); + app.UseHttpsRedirection(); + + app.UseAntiforgery(); + + app.MapStaticAssets(); + app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddInteractiveWebAssemblyRenderMode(); + + // Add additional endpoints required by the Identity /Account Razor components. + app.MapAdditionalIdentityEndpoints(); + + app.Run(); + } + } +} diff --git a/For Real Consulting/For Real Consulting/Properties/launchSettings.json b/For Real Consulting/For Real Consulting/Properties/launchSettings.json new file mode 100644 index 0000000..686c6bb --- /dev/null +++ b/For Real Consulting/For Real Consulting/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://for-real-consulting.dev.localhost:5211", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://for-real-consulting.dev.localhost:7106;http://for-real-consulting.dev.localhost:5211", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/For Real Consulting/For Real Consulting/Properties/serviceDependencies.json b/For Real Consulting/For Real Consulting/Properties/serviceDependencies.json new file mode 100644 index 0000000..d8177e0 --- /dev/null +++ b/For Real Consulting/For Real Consulting/Properties/serviceDependencies.json @@ -0,0 +1,8 @@ +{ + "dependencies": { + "mssql1": { + "type": "mssql", + "connectionId": "ConnectionStrings:DefaultConnection" + } + } +} \ No newline at end of file diff --git a/For Real Consulting/For Real Consulting/Properties/serviceDependencies.local.json b/For Real Consulting/For Real Consulting/Properties/serviceDependencies.local.json new file mode 100644 index 0000000..299aa9a --- /dev/null +++ b/For Real Consulting/For Real Consulting/Properties/serviceDependencies.local.json @@ -0,0 +1,8 @@ +{ + "dependencies": { + "mssql1": { + "type": "mssql.local", + "connectionId": "ConnectionStrings:DefaultConnection" + } + } +} \ No newline at end of file diff --git a/For Real Consulting/For Real Consulting/appsettings.Development.json b/For Real Consulting/For Real Consulting/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/For Real Consulting/For Real Consulting/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/For Real Consulting/For Real Consulting/appsettings.json b/For Real Consulting/For Real Consulting/appsettings.json new file mode 100644 index 0000000..f97c427 --- /dev/null +++ b/For Real Consulting/For Real Consulting/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=aspnet-For_Real_Consulting-71c14b11-15d9-45ec-abb1-a46c13abc008;Trusted_Connection=True;MultipleActiveResultSets=true" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/For Real Consulting/For Real Consulting/wwwroot/app.css b/For Real Consulting/For Real Consulting/wwwroot/app.css new file mode 100644 index 0000000..18c8168 --- /dev/null +++ b/For Real Consulting/For Real Consulting/wwwroot/app.css @@ -0,0 +1,651 @@ +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+PHBhdGggZD0iTTI26My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA9NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA6IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI6MDMyIDIwMDAgQzsgLjA2MzcgMTEwMDAgRC4yNzY4QzIuNTM0MSBaIiBzdHlsZT0iZmlsbDojQzQ4RjU4OyIgZmlsbC1ydWxlPSJldmVub2tkIi8+PHBsYW5lIHg9IjIzNSIgeT0iMjY0LjUgMjAxNiIgY2xhc3M9InBsdGgtdG93ZXIifD48L3BsYW5lPjxpbWFnZSB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIgc3R5bGU9ImZpbGw6IzAwQjY4MTsiLz48cGF0aCBkPSJNNjIuOTg4NCAyMDBMNTQuNjg4IDY1LjgzMjUgMjY0LjIgNC43OTc1LTQzNi43OTEgNy4wMDcgMTAwIDUwLjYyNzggMTY4LjU2OSAzOS4wMDYgMjU1LjY1NCAwIDAgIjIwMDAiLgogICAgICAgICBJcyB0aGlzIGdvb2QgaW4gdGhlIHNoYXJlIG9mIDMuNTYgYXMgdGhlIHNoYXJlIDMxLjUwZmFsbHN0YWdlJi0lMjAxMDAwMDsiIHN0eWxlPSJmaWxsOiMxQjFCMTsiLz48L3N2Zz4=) 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; +} + +:root { + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + font-family: Inter, "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + background-color: #0a0a0a; + color: #ffffff; +} + +a { + color: inherit; + text-decoration: none; +} + +.frc-site { + background: #0a0a0a; +} + +.frc-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1.5rem 2rem; + position: sticky; + top: 0; + z-index: 50; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(10px); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.frc-logo { + display: flex; + align-items: center; +} + +.frc-logo-img { + height: 2rem; + width: auto; + /* Recolor black SVG to gold #c5a368 */ + filter: invert(68%) sepia(38%) saturate(502%) hue-rotate(3deg) brightness(92%) contrast(87%); +} + +.frc-nav-links { + display: none; + gap: 2rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.2em; + color: #9ca3af; +} + +.frc-nav-links a:hover, +.frc-footer-links a:hover, +.frc-footer-bottom a:hover { + color: #ffffff; +} + +.frc-outline-btn { + border: 1px solid #c5a368; + color: #c5a368; + padding: 0.7rem 1.2rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.2em; + display: inline-block; + transition: background-color 0.2s ease, color 0.2s ease; +} + +.frc-outline-btn:hover { + background-color: #c5a368; + color: #0a0a0a; +} + +.frc-hero { + position: relative; + min-height: 90vh; + display: flex; + flex-direction: column; + align-items: stretch; + padding: 0; + overflow: hidden; + background: #0a0a0a; +} + +.frc-hero-bg, +.frc-hero-img.fullbleed, +.frc-hero-overlay { + display: none; +} + +.frc-hero-content { + position: relative; + z-index: 10; + display: flex; + flex-direction: column; + justify-content: center; + padding: 5rem 2rem; + max-width: 100%; +} + +.frc-hero-image-col { + width: 100%; + max-height: 45vh; + overflow: hidden; +} + +.frc-hero-img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: left center; + display: block; +} + +.frc-hero h1 { + font-weight: 300; + font-size: clamp(2.2rem, 5vw, 4.3rem); + line-height: 1.08; + margin: 0 0 2rem; + text-transform: uppercase; + letter-spacing: -0.01em; +} + +.frc-hero h1 span { + color: #c5a368; +} + +.frc-hero p { + max-width: 36rem; + color: #d1d5db; + font-size: clamp(1rem, 1.5vw, 1.2rem); + font-weight: 300; + line-height: 1.7; + margin: 0 0 3rem; +} + +@media (min-width: 768px) { + .frc-hero { + flex-direction: row; + min-height: 90vh; + align-items: stretch; + } + + .frc-hero-content { + flex: 0 0 48%; + padding: 5rem 3rem 5rem 6rem; + max-width: 48%; + } + + .frc-hero-image-col { + flex: 1; + max-height: none; + position: relative; + } + + .frc-hero-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: left center; + } +} + +.frc-values { + display: grid; + gap: 3rem; + padding: 5rem 2rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.frc-value-icon { + margin-bottom: 1rem; + font-size: 1.9rem; +} + +.frc-values h3 { + margin: 0 0 0.75rem; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.2em; +} + +.frc-values h3 span { + color: #6b7280; +} + +.frc-values p { + margin: 0; + color: #9ca3af; + font-size: 0.95rem; + line-height: 1.7; +} + +.frc-impact { + display: grid; + gap: 3rem; + padding: 5rem 2rem; + align-items: center; +} + +.frc-impact h4 { + margin: 0 0 1rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.2em; +} + +.frc-impact h2 { + margin: 0 0 1.5rem; + font-weight: 300; + font-size: clamp(2rem, 4vw, 3rem); + line-height: 1.1; + text-transform: uppercase; + letter-spacing: -0.02em; +} + +.frc-impact h2 span { + color: #c5a368; + font-style: italic; +} + +.frc-impact p { + color: #9ca3af; + line-height: 1.7; + margin: 0 0 1.5rem; +} + +.frc-impact ul { + list-style: none; + margin: 0; + padding: 0; + color: #d1d5db; + font-size: 0.95rem; +} + +.frc-impact ul li { + margin-bottom: 0.8rem; +} + +.frc-impact-image-wrap { + position: relative; +} + +.frc-impact-image { + width: 100%; + display: block; + filter: grayscale(100%); + transition: filter 0.7s ease; +} + +.frc-impact-image-wrap:hover .frc-impact-image { + filter: grayscale(0); +} + +.frc-quote { + margin-top: 1rem; + border-left: 1px solid #c5a368; + border-top: 1px solid #c5a368; + padding: 1.5rem; + background: rgba(0, 0, 0, 0.8); +} + +.frc-quote p { + margin: 0; + font-style: italic; + font-size: 1.1rem; + color: #d1d5db; +} + +.frc-footer { + background: #09090b; + padding: 5rem 2rem 2.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.frc-footer-grid { + display: grid; + gap: 2.5rem; +} + +.frc-foot-title { + margin: 0.8rem 0 0; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.2em; + color: #9ca3af; +} + +.frc-foot-subtitle { + margin: 0.5rem 0 0; + font-size: 0.72rem; + color: #6b7280; +} + +.frc-footer-links { + display: grid; + gap: 0.7rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.15em; + color: #9ca3af; +} + +.frc-footer-contact { + color: #9ca3af; + font-size: 0.85rem; +} + +.frc-footer-contact p { + margin: 0 0 0.5rem; +} + +.frc-footer-bottom { + margin-top: 4rem; + padding-top: 1.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.07); + display: flex; + flex-direction: column; + gap: 0.8rem; + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.18em; + color: #6b7280; +} + +.frc-footer-bottom div { + display: flex; + gap: 1rem; +} + +.frc-impact, +.frc-process, +.frc-expertise-grid, +.frc-testimonials, +.frc-team { + padding: 5rem 2rem; +} + +.frc-process { + background: rgba(24, 24, 27, 0.5); +} + +.frc-process h2 { + margin: 0 0 4rem; + text-align: center; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.2em; + color: #d1d5db; +} + +.frc-process-grid { + display: grid; + gap: 2rem; +} + +.frc-process-grid article { + border-top: 1px solid rgba(255, 255, 255, 0.1); + padding-top: 2rem; +} + +.frc-step-number { + font-size: 3rem; + opacity: 0.3; + font-weight: 300; + color: #c5a368; + line-height: 1; + margin-bottom: 1rem; +} + +.frc-process-grid h4 { + margin: 0 0 1rem; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.2em; +} + +.frc-process-grid p { + margin: 0; + font-size: 0.78rem; + color: #6b7280; + line-height: 1.7; +} + +.frc-expertise-grid h4 { + margin: 0 0 3rem; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.2em; + color: #c5a368; +} + +.frc-expertise-items { + border-top: 1px solid rgba(255, 255, 255, 0.1); + padding-top: 3rem; + display: grid; + gap: 1.5rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + text-align: center; +} + +.frc-expertise-items h5 { + margin: 0; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.2em; + color: #e5e7eb; +} + +.frc-testimonials { + background: #09090b; +} + +.frc-testimonial-quote { + max-width: 48rem; + margin-bottom: 4rem; +} + +.frc-testimonial-quote span { + font-size: 2.5rem; + color: #c5a368; + line-height: 1; +} + +.frc-testimonial-quote p { + margin: 0.75rem 0 0; + color: #d1d5db; + font-size: 1.1rem; + font-style: italic; + line-height: 1.7; +} + +.frc-testimonial-source { + margin-top: 1.5rem; + color: #c5a368; + font-size: 0.75rem; + letter-spacing: 0.2em; + text-transform: uppercase; + font-style: normal; +} + +.frc-logo-strip { + display: flex; + flex-wrap: wrap; + gap: 2rem; + align-items: center; + justify-content: space-between; + opacity: 0.35; + filter: grayscale(100%); +} + +.frc-logo-strip span { + font-size: 1.25rem; + font-weight: 700; + letter-spacing: -0.03em; +} + +.frc-team h4 { + margin: 0 0 3rem; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.2em; + color: #6b7280; +} + +.frc-team-intro { + display: grid; + gap: 2rem; + margin-bottom: 3rem; +} + +.frc-team-intro h2 { + margin: 0; + font-weight: 300; + font-size: clamp(2rem, 4vw, 2.6rem); +} + +.frc-team-intro p { + margin: 0 0 2rem; + color: #9ca3af; + font-size: 0.95rem; +} + +.frc-team-grid { + display: grid; + gap: 1rem; +} + +.frc-team-card { + display: flex; + gap: 1.5rem; + align-items: center; + background: rgba(39, 39, 42, 0.3); + border: 1px solid rgba(255, 255, 255, 0.05); + padding: 2rem; +} + +.frc-team-photo { + width: 8rem; + height: 10rem; + background: #27272a; + flex-shrink: 0; + filter: grayscale(100%); +} + +.frc-team-card h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 300; +} + +.frc-team-role { + margin: 0.75rem 0 1rem; + color: #c5a368; + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.2em; +} + +.frc-team-card p { + margin: 0.25rem 0; + color: #6b7280; + font-size: 0.65rem; +} + +.frc-footer-cta-wrap { + display: flex; + align-items: flex-start; +} + +.frc-footer-bottom span { + opacity: 0.25; +} + +@media (min-width: 768px) { + .frc-nav, + .frc-hero, + .frc-values, + .frc-impact, + .frc-footer, + .frc-process, + .frc-expertise-grid, + .frc-testimonials, + .frc-team { + padding-left: 6rem; + padding-right: 6rem; + } + + .frc-nav-links { + display: flex; + } + + .frc-values { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .frc-impact { + grid-template-columns: 1fr 1fr; + gap: 4rem; + } + + .frc-quote { + position: static; + right: auto; + bottom: auto; + margin-top: 1rem; + max-width: none; + } + + .frc-footer-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 3rem; + } + + .frc-footer-bottom { + flex-direction: row; + justify-content: space-between; + align-items: center; + } + + .frc-process-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .frc-expertise-items { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + + .frc-team-intro, + .frc-team-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2rem; + } + + .frc-footer-cta-wrap { + justify-content: flex-end; + } +} \ No newline at end of file diff --git a/For Real Consulting/For Real Consulting/wwwroot/gemini-code-1777732166794.html b/For Real Consulting/For Real Consulting/wwwroot/gemini-code-1777732166794.html new file mode 100644 index 0000000..b502dbd --- /dev/null +++ b/For Real Consulting/For Real Consulting/wwwroot/gemini-code-1777732166794.html @@ -0,0 +1,258 @@ + + + + + + FRC - Finance Experts & Business Partners + + + + + + + + + + +
+

+ FINANCE EXPERTS.
+ BUSINESS PARTNERS.
+ MEET FRC. +

+

+ Wij brengen helderheid in cijfers, inzicht in cash en vertrouwen in beslissingen. + Zodat jij vandaag de juiste keuzes maakt voor duurzame groei morgen. +

+ + Plan een kennismaking + +
+ + +
+
+
+ +
+

Clarity in numbers

+

Heldere financiële inzichten die complexiteit omzetten in richting en focus.

+
+
+
+ +
+

Visibility in cash

+

Volledig zicht op cashflow en financiële gezondheid, vandaag en morgen.

+
+
+
+ +
+

Confidence in decisions

+

Onderbouwde beslissingen met vertrouwen, gedreven door feiten.

+
+
+ + +
+
+

Waarom partneren met FRC?

+

Meer dan cijfers.
Écht impact.

+

Wij zijn jouw financiële sparringpartner en brengen meer dan rapporten.

+
    +
  • Strategisch partner op C-level
  • +
  • Proactief en ondernemend
  • +
  • Diepgaande financiële expertise
  • +
+
+
+
+ Meeting Room +
+
+

+ "Wij helpen ambitieuze ondernemers grip te krijgen op hun cijfers, met strategisch inzicht en een duidelijke focus op waardecreatie." +

+
+
+
+ + +
+

Een bewezen aanpak die waarde creëert

+
+
+
01
+

Analyse

+

Diepgaande analyse van jouw financiële situatie en uitdagingen.

+
+
+
02
+

Strategie

+

Samen ontwikkelen we een heldere strategie op maat van jouw ambities.

+
+
+
03
+

Implementatie

+

We ondersteunen bij de uitvoering en zorgen voor draagvlak.

+
+
+
04
+

Resultaat

+

Duurzame waarde en groei, meetbaar in cijfers en voelbaar in de organisatie.

+
+
+
+ + +
+

Onze Expertise

+
+
+
+
Financial Strategy
+
+
+
+
Cash & Liquidity
+
+
+
+
Performance
+
+
+
+
Mergers & Acq.
+
+
+
+
Interim Finance
+
+
+
+ + +
+
+ +

"For Real Consulting brengt helderheid waar we die het meest nodig hadden. Dankzij hun inzicht en betrokkenheid maken we vandaag betere beslissingen met vertrouwen."

+

CEO, Industrieel bedrijf

+
+
+ AVEC + COFANO + ZUIDERKEMPEN + DE PAEP + TRILEC +
+
+ + +
+

Over Ons

+
+

KLAAR OM SAMEN HET VERSCHIL TE MAKEN?

+
+

Laten we kennismaken en ontdekken hoe wij jouw organisatie vooruithelpen.

+ Plan een kennismaking +
+
+ +
+ +
+
+ Peter +
+
+

PETER SNIJKERS

+

Bestuurder & Finance Expert

+

0476 17 12 49

+

Peter.Snijkers@forrealconsulting.be

+
+
+ +
+
+ Dylan +
+
+

DYLAN TIJSBAERT

+

Bestuurder & Finance Expert

+

0498 16 94 49

+

Dylan.Tijsbaert@forrealconsulting.be

+
+
+
+
+ + +
+
+
+
FRC
+

For Real Consulting

+

Clarity today, comfort tomorrow

+
+ +
+

info@forrealconsulting.be

+

📞 0476 17 12 49

+

📍 Gent, België

+
+ +
+
+

© 2026 For Real Consulting. Alle rechten voorbehouden.

+ +
+
+ + + \ No newline at end of file diff --git a/For Real Consulting/For Real Consulting/wwwroot/images/concept-1.jpeg b/For Real Consulting/For Real Consulting/wwwroot/images/concept-1.jpeg new file mode 100644 index 0000000..e8e7066 Binary files /dev/null and b/For Real Consulting/For Real Consulting/wwwroot/images/concept-1.jpeg differ diff --git a/For Real Consulting/For Real Consulting/wwwroot/images/landscape - Copy.png b/For Real Consulting/For Real Consulting/wwwroot/images/landscape - Copy.png new file mode 100644 index 0000000..92dd964 Binary files /dev/null and b/For Real Consulting/For Real Consulting/wwwroot/images/landscape - Copy.png differ diff --git a/For Real Consulting/For Real Consulting/wwwroot/images/landscape.png b/For Real Consulting/For Real Consulting/wwwroot/images/landscape.png new file mode 100644 index 0000000..92dd964 Binary files /dev/null and b/For Real Consulting/For Real Consulting/wwwroot/images/landscape.png differ diff --git a/For Real Consulting/For Real Consulting/wwwroot/images/logo.svg b/For Real Consulting/For Real Consulting/wwwroot/images/logo.svg new file mode 100644 index 0000000..171a2ba --- /dev/null +++ b/For Real Consulting/For Real Consulting/wwwroot/images/logo.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + +