Skip to content

Commit

Permalink
feat: blazing pizza workshop as htmxor app
Browse files Browse the repository at this point in the history
  • Loading branch information
egil committed Apr 29, 2024
1 parent d627543 commit f8eb5e0
Show file tree
Hide file tree
Showing 76 changed files with 4,142 additions and 323 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -482,3 +482,5 @@ $RECYCLE.BIN/

# Vim temporary swap files
*.swp
/samples/BlazingPizza/Data/pizza.db-wal
/samples/BlazingPizza/Data/pizza.db-shm
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@

This packages enables Blazor Static SSR (.NET 8 and later) to be used seamlessly with Htmx.

Blazor Static SSR
comes with basic interactivity via enhanced navigation and enhanced form handling.
Blazor Static SSR comes with basic interactivity via enhanced navigation and enhanced form handling.
Adding Htmx (htmx.org) to the mix gives you access to another level of interactivity while still
retaining all the advantages of Blazor SSR stateless nature.

**NOTE:** _This package is highly experimental!_

**Nuget:** https://www.nuget.org/packages/Htmxor

## Samples used for comparison:
## Samples

- [BlazorSSR - pure Blazor SSR](https://github.com/egil/Htmxor/tree/main/samples/BlazorSSR)
- [HtmxBlazorSSR - pure Blazor SSR](https://github.com/egil/Htmxor/tree/main/samples/HtmxBlazorSSR)
The following Blazor Web Apps (Htmxor) are used to test Htmxor and demo the capabilities of it.

- [Blazing Pizza workshop as Htmxor App](https://github.com/egil/Htmxor/tree/main/samples/BlazingPizza)
- [Htmxor - TestApp](https://github.com/egil/Htmxor/tree/main/test/Htmxor.TestApp)
31 changes: 19 additions & 12 deletions samples/BlazingPizza/BlazingPizza.csproj
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>aspnet-htmxor-blazing-pizza-cc288be7-d93a-4590-9fd4-df012ba2fbcf</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.4" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.4" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Htmxor\Htmxor.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Htmxor\Htmxor.csproj" />
</ItemGroup>

<ItemGroup>
<None Update="Data\pizza.db" CopyToOutputDirectory="PreserveNewest" ExcludeFromSingleFile="true" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using BlazingPizza.Components.Account.Pages;
using BlazingPizza.Components.Account.Pages.Manage;
using BlazingPizza.Data;

namespace Microsoft.AspNetCore.Routing;

internal static class IdentityComponentsEndpointRouteBuilderExtensions
{
// These endpoints are required by the Identity Razor components defined in the /Components/Account/Pages directory of this project.
public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);

var accountGroup = endpoints.MapGroup("/Account");

accountGroup.MapPost("/PerformExternalLogin", (
HttpContext context,
[FromServices] SignInManager<PizzaStoreUser> signInManager,
[FromForm] string provider,
[FromForm] string returnUrl) =>
{
IEnumerable<KeyValuePair<string, StringValues>> query = [
new("ReturnUrl", returnUrl),
new("Action", ExternalLogin.LoginCallbackAction)];
var redirectUrl = UriHelper.BuildRelative(
context.Request.PathBase,
"/Account/ExternalLogin",
QueryString.Create(query));
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
return TypedResults.Challenge(properties, [provider]);
});

accountGroup.MapPost("/Logout", async (
ClaimsPrincipal user,
SignInManager<PizzaStoreUser> signInManager,
[FromForm] string returnUrl) =>
{
await signInManager.SignOutAsync();
return TypedResults.LocalRedirect($"~/{returnUrl}");
});

var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization();

manageGroup.MapPost("/LinkExternalLogin", async (
HttpContext context,
[FromServices] SignInManager<PizzaStoreUser> signInManager,
[FromForm] string provider) =>
{
// Clear the existing external cookie to ensure a clean login process
await context.SignOutAsync(IdentityConstants.ExternalScheme);
var redirectUrl = UriHelper.BuildRelative(
context.Request.PathBase,
"/Account/Manage/ExternalLogins",
QueryString.Create("Action", ExternalLogins.LinkLoginCallbackAction));
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, signInManager.UserManager.GetUserId(context.User));
return TypedResults.Challenge(properties, [provider]);
});

var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var downloadLogger = loggerFactory.CreateLogger("DownloadPersonalData");

manageGroup.MapPost("/DownloadPersonalData", async (
HttpContext context,
[FromServices] UserManager<PizzaStoreUser> userManager,
[FromServices] AuthenticationStateProvider authenticationStateProvider) =>
{
var user = await userManager.GetUserAsync(context.User);
if (user is null)
{
return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
}
var userId = await userManager.GetUserIdAsync(user);
downloadLogger.LogInformation("User with ID '{UserId}' asked for their personal data.", userId);
// Only include personal data for download
var personalData = new Dictionary<string, string>();
var personalDataProps = typeof(PizzaStoreUser).GetProperties().Where(
prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute)));
foreach (var p in personalDataProps)
{
personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null");
}
var logins = await userManager.GetLoginsAsync(user);
foreach (var l in logins)
{
personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey);
}
personalData.Add("Authenticator Key", (await userManager.GetAuthenticatorKeyAsync(user))!);
var fileBytes = JsonSerializer.SerializeToUtf8Bytes(personalData);
context.Response.Headers.TryAdd("Content-Disposition", "attachment; filename=PersonalData.json");
return TypedResults.File(fileBytes, contentType: "application/json", fileDownloadName: "PersonalData.json");
});

return accountGroup;
}
}
20 changes: 20 additions & 0 deletions samples/BlazingPizza/Components/Account/IdentityNoOpEmailSender.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using BlazingPizza.Data;

namespace BlazingPizza.Components.Account;

// Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation.
internal sealed class IdentityNoOpEmailSender : IEmailSender<PizzaStoreUser>
{
private readonly IEmailSender emailSender = new NoOpEmailSender();

public Task SendConfirmationLinkAsync(PizzaStoreUser user, string email, string confirmationLink) =>
emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by <a href='{confirmationLink}'>clicking here</a>.");

public Task SendPasswordResetLinkAsync(PizzaStoreUser user, string email, string resetLink) =>
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by <a href='{resetLink}'>clicking here</a>.");

public Task SendPasswordResetCodeAsync(PizzaStoreUser user, string email, string resetCode) =>
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}");
}
58 changes: 58 additions & 0 deletions samples/BlazingPizza/Components/Account/IdentityRedirectManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;

namespace BlazingPizza.Components.Account;

internal sealed class IdentityRedirectManager(NavigationManager navigationManager)
{
public const string StatusCookieName = "Identity.StatusMessage";

private static readonly CookieBuilder StatusCookieBuilder = new()
{
SameSite = SameSiteMode.Strict,
HttpOnly = true,
IsEssential = true,
MaxAge = TimeSpan.FromSeconds(5),
};

[DoesNotReturn]
public void RedirectTo(string? uri)
{
uri ??= "";

// Prevent open redirects.
if (!Uri.IsWellFormedUriString(uri, UriKind.Relative))
{
uri = navigationManager.ToBaseRelativePath(uri);
}

// During static rendering, NavigateTo throws a NavigationException which is handled by the framework as a redirect.
// So as long as this is called from a statically rendered Identity component, the InvalidOperationException is never thrown.
navigationManager.NavigateTo(uri);
throw new InvalidOperationException($"{nameof(IdentityRedirectManager)} can only be used during static rendering.");
}

[DoesNotReturn]
public void RedirectTo(string uri, Dictionary<string, object?> queryParameters)
{
var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path);
var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters);
RedirectTo(newUri);
}

[DoesNotReturn]
public void RedirectToWithStatus(string uri, string message, HttpContext context)
{
context.Response.Cookies.Append(StatusCookieName, message, StatusCookieBuilder.Build(context));
RedirectTo(uri);
}

private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path);

[DoesNotReturn]
public void RedirectToCurrentPage() => RedirectTo(CurrentPath);

[DoesNotReturn]
public void RedirectToCurrentPageWithStatus(string message, HttpContext context)
=> RedirectToWithStatus(CurrentPath, message, context);
}
19 changes: 19 additions & 0 deletions samples/BlazingPizza/Components/Account/IdentityUserAccessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Identity;
using BlazingPizza.Data;

namespace BlazingPizza.Components.Account;

internal sealed class IdentityUserAccessor(UserManager<PizzaStoreUser> userManager, IdentityRedirectManager redirectManager)
{
public async Task<PizzaStoreUser> GetRequiredUserAsync(HttpContext context)
{
var user = await userManager.GetUserAsync(context.User);

if (user is null)
{
redirectManager.RedirectToWithStatus("Account/InvalidUser", $"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context);
}

return user;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@page "/Account/AccessDenied"

<PageTitle>Access denied</PageTitle>

<header>
<h1 class="text-danger">Access denied</h1>
<p class="text-danger">You do not have access to this resource.</p>
</header>
48 changes: 48 additions & 0 deletions samples/BlazingPizza/Components/Account/Pages/ConfirmEmail.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
@page "/Account/ConfirmEmail"

@using System.Text
@using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities
@using BlazingPizza.Data

@inject UserManager<PizzaStoreUser> UserManager
@inject IdentityRedirectManager RedirectManager

<PageTitle>Confirm email</PageTitle>

<h1>Confirm email</h1>
<StatusMessage Message="@statusMessage" />

@code {
private string? statusMessage;

[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;

[SupplyParameterFromQuery]
private string? UserId { get; set; }

[SupplyParameterFromQuery]
private string? Code { get; set; }

protected override async Task OnInitializedAsync()
{
if (UserId is null || Code is null)
{
RedirectManager.RedirectTo("");
}

var user = await UserManager.FindByIdAsync(UserId);
if (user is null)
{
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
statusMessage = $"Error loading user with ID {UserId}";
}
else
{
var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code));
var result = await UserManager.ConfirmEmailAsync(user, code);
statusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email.";
}
}
}
Loading

0 comments on commit f8eb5e0

Please sign in to comment.