Isolate anonymous auth page from Blazor

This commit is contained in:
2026-05-02 23:31:49 +02:00
parent d3ba75ce42
commit 2d2ed561cc
6 changed files with 299 additions and 97 deletions

View File

@@ -1,3 +1,6 @@
@using RpgRoller.Api
@using RpgRoller.Components.Pages.HomeControls
@using RpgRoller.Services
@attribute [ExcludeFromCodeCoverage]
<!DOCTYPE html>
@@ -12,20 +15,64 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap" rel="stylesheet">
<HeadOutlet @rendermode="InteractiveServer"/>
@if (UseInteractiveApp)
{
<HeadOutlet @rendermode="@(new InteractiveServerRenderMode(prerender: false))"/>
}
</head>
<body>
<Routes @rendermode="InteractiveServer"/>
@if (UseStaticAuthPage)
{
<StaticAuthPage StatusMessage="@AuthStatusMessage" StatusIsError="@AuthStatusIsError"/>
}
else
{
<Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))"/>
}
<script src="js/rpgroller-api.js"></script>
<script src="_framework/blazor.web.js"></script>
@if (UseInteractiveApp)
{
<script src="_framework/blazor.web.js"></script>
}
</body>
</html>
@code {
[Inject]
private IGameService GameService { get; set; } = null!;
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private bool UseInteractiveApp => !UseStaticAuthPage;
private bool UseStaticAuthPage => IsRootRequest && !HasAuthenticatedSession;
private bool IsRootRequest
{
get
{
var path = HttpContext?.Request.Path.Value;
return string.IsNullOrWhiteSpace(path) || string.Equals(path, "/", StringComparison.Ordinal);
}
}
private bool HasAuthenticatedSession
{
get
{
if (HttpContext is null)
return false;
var sessionToken = HttpContext.Request.Cookies[SessionCookie.Name];
return !string.IsNullOrWhiteSpace(sessionToken) && GameService.GetUserBySession(sessionToken) is not null;
}
}
private string? AuthStatusMessage => ReadAuthQueryValue("message");
private bool AuthStatusIsError => string.Equals(ReadAuthQueryValue("kind"), "error", StringComparison.OrdinalIgnoreCase);
private string BaseHref
{
get
@@ -38,4 +85,13 @@
}
}
}
private string? ReadAuthQueryValue(string key)
{
if (!UseStaticAuthPage || HttpContext is null)
return null;
var value = HttpContext.Request.Query[key];
return value.Count > 0 ? value[0] : null;
}
}

View File

@@ -1,27 +1,2 @@
@page "/"
@using RpgRoller.Components.Pages.HomeControls
@switch (CurrentView)
{
case HomeViewMode.Loading:
<div class="rr-app">
<main class="loading-shell" aria-busy="true" aria-live="polite">
<h1>RpgRoller</h1>
<p>Connecting...</p>
</main>
</div>
break;
case HomeViewMode.Anonymous:
<div class="rr-app">
<AuthSection
StatusMessage="StatusMessage"
StatusIsError="StatusIsError"
LoggedIn="OnLoggedInAsync"/>
</div>
break;
case HomeViewMode.Workspace:
<Workspace LoggedOut="OnLoggedOutAsync"/>
break;
}
<Workspace LoggedOut="OnLoggedOutAsync"/>

View File

@@ -1,83 +1,29 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
using Microsoft.AspNetCore.WebUtilities;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public partial class Home
{
protected override async Task OnAfterRenderAsync(bool firstRender)
private Task OnLoggedOutAsync(string? message)
{
if (!firstRender || HasInitialized)
return;
HasInitialized = true;
await InitializeAsync();
await InvokeAsync(StateHasChanged);
}
private async Task InitializeAsync()
{
try
if (string.IsNullOrWhiteSpace(message))
{
_ = await ApiClient.RequestAsync<MeResponse>("GET", "/api/me");
CurrentView = HomeViewMode.Workspace;
ClearStatus();
Navigation.NavigateTo("/", forceLoad: true);
return Task.CompletedTask;
}
catch (ApiRequestException ex) when (ex.StatusCode == 401)
{
CurrentView = HomeViewMode.Anonymous;
ClearStatus();
}
catch (ApiRequestException ex)
{
CurrentView = HomeViewMode.Anonymous;
SetStatus(ex.Message, true);
}
}
private Task OnLoggedInAsync()
{
ClearStatus();
Navigation.NavigateTo("/", forceLoad: true);
var query = new Dictionary<string, string?>
{
["message"] = message,
["kind"] = message.Contains("expired", StringComparison.OrdinalIgnoreCase) ? "error" : "success"
};
Navigation.NavigateTo(QueryHelpers.AddQueryString("/", query), forceLoad: true);
return Task.CompletedTask;
}
private Task OnLoggedOutAsync(string? message)
{
CurrentView = HomeViewMode.Anonymous;
if (string.IsNullOrWhiteSpace(message))
{
ClearStatus();
return InvokeAsync(StateHasChanged);
}
var isError = message.Contains("expired", StringComparison.OrdinalIgnoreCase);
SetStatus(message, isError);
return InvokeAsync(StateHasChanged);
}
private void SetStatus(string message, bool isError)
{
StatusMessage = message;
StatusIsError = isError;
}
private void ClearStatus()
{
StatusMessage = null;
StatusIsError = false;
}
private HomeViewMode CurrentView { get; set; } = HomeViewMode.Loading;
private string? StatusMessage { get; set; }
private bool StatusIsError { get; set; }
private bool HasInitialized { get; set; }
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
[Inject]
private NavigationManager Navigation { get; set; } = null!;
[Inject] private NavigationManager Navigation { get; set; } = null!;
}

View File

@@ -0,0 +1,55 @@
<div class="rr-app" data-auth-page>
<main class="auth-shell">
<h1>RpgRoller</h1>
<p class="auth-subtitle">Register or log in to join a campaign session.</p>
<p class="status-message @(StatusIsError ? "error" : "success")"
data-auth-status
aria-live="polite"
hidden="@string.IsNullOrWhiteSpace(StatusMessage)">@StatusMessage</p>
<div class="auth-grid">
<section class="card auth-card">
<h2>Register</h2>
<p class="form-error" data-form-error hidden></p>
<form class="form-grid" data-auth-form="register" novalidate>
<label for="register-username">Username</label>
<input id="register-username" name="username" autocomplete="username"/>
<p class="field-error" data-field-error="username" hidden></p>
<label for="register-display-name">Display name</label>
<input id="register-display-name" name="displayName" autocomplete="name"/>
<p class="field-error" data-field-error="displayName" hidden></p>
<label for="register-password">Password</label>
<input id="register-password" name="password" type="password" autocomplete="new-password"/>
<p class="field-error" data-field-error="password" hidden></p>
<button type="submit" data-submit-label="Register" data-submitting-label="Registering...">Register</button>
</form>
</section>
<section class="card auth-card">
<h2>Login</h2>
<p class="form-error" data-form-error hidden></p>
<form class="form-grid" data-auth-form="login" novalidate>
<label for="login-username">Username</label>
<input id="login-username" name="username" autocomplete="username"/>
<p class="field-error" data-field-error="username" hidden></p>
<label for="login-password">Password</label>
<input id="login-password" name="password" type="password" autocomplete="current-password"/>
<p class="field-error" data-field-error="password" hidden></p>
<button type="submit" data-submit-label="Login" data-submitting-label="Logging in...">Login</button>
</form>
</section>
</div>
</main>
</div>
@code {
[Parameter]
public string? StatusMessage { get; set; }
[Parameter]
public bool StatusIsError { get; set; }
}

View File

@@ -214,6 +214,164 @@ window.rpgRollerApi = (() => {
element.value = "";
}
function initializeAuthPage() {
const root = document.querySelector("[data-auth-page]");
if (!root) {
return;
}
const statusElement = root.querySelector("[data-auth-status]");
const forms = root.querySelectorAll("[data-auth-form]");
forms.forEach((form) => {
form.addEventListener("submit", async (event) => {
event.preventDefault();
await submitAuthForm(root, form, statusElement);
});
});
}
function clearAuthErrors(root) {
const statusElement = root.querySelector("[data-auth-status]");
if (statusElement) {
statusElement.hidden = true;
statusElement.textContent = "";
statusElement.classList.remove("error", "success");
}
root.querySelectorAll("[data-form-error], [data-field-error]").forEach((element) => {
element.hidden = true;
element.textContent = "";
});
}
function setAuthStatus(statusElement, message, isError) {
if (!statusElement) {
return;
}
statusElement.hidden = !message;
statusElement.textContent = message || "";
statusElement.classList.toggle("error", !!message && isError);
statusElement.classList.toggle("success", !!message && !isError);
}
function setFormError(form, message) {
const errorElement = form.querySelector("[data-form-error]");
if (!errorElement) {
return;
}
errorElement.hidden = !message;
errorElement.textContent = message || "";
}
function setFieldError(form, fieldName, message) {
const errorElement = form.querySelector(`[data-field-error="${fieldName}"]`);
if (!errorElement) {
return;
}
errorElement.hidden = !message;
errorElement.textContent = message || "";
}
function readFormData(form) {
return Object.fromEntries(new FormData(form).entries());
}
function validateAuthForm(formType, payload) {
const errors = {};
if (!payload.username || !payload.username.trim()) {
errors.username = "Username is required.";
}
if (formType === "register") {
if (!payload.displayName || !payload.displayName.trim()) {
errors.displayName = "Display name is required.";
}
if (!payload.password || payload.password.length < 8) {
errors.password = "Password must be at least 8 characters.";
}
} else if (!payload.password) {
errors.password = "Password is required.";
}
return errors;
}
function setSubmitting(form, isSubmitting) {
const submitButton = form.querySelector('button[type="submit"]');
if (!submitButton) {
return;
}
submitButton.disabled = isSubmitting;
submitButton.textContent = isSubmitting
? submitButton.dataset.submittingLabel || submitButton.textContent
: submitButton.dataset.submitLabel || submitButton.textContent;
}
async function submitAuthForm(root, form, statusElement) {
clearAuthErrors(root);
const formType = form.dataset.authForm;
const payload = readFormData(form);
const errors = validateAuthForm(formType, payload);
Object.entries(errors).forEach(([fieldName, message]) => {
setFieldError(form, fieldName, message);
});
if (Object.keys(errors).length > 0) {
setFormError(form, "Resolve validation issues before submitting.");
return;
}
const endpoint = formType === "register" ? "/api/auth/register" : "/api/auth/login";
const requestBody = formType === "register"
? {
username: payload.username.trim(),
displayName: payload.displayName.trim(),
password: payload.password
}
: {
username: payload.username.trim(),
password: payload.password
};
setSubmitting(form, true);
try {
const response = await request("POST", endpoint, requestBody);
if (!response.ok) {
if (formType === "register" && response.code === "duplicate_username") {
setFieldError(form, "username", "Username is already taken. Choose another one.");
} else {
setFormError(form, response.error || "Request failed.");
}
return;
}
if (formType === "login") {
window.location.assign(toAppUrl("/"));
return;
}
form.reset();
setAuthStatus(statusElement, "Registration successful. You can log in now.", false);
} finally {
setSubmitting(form, false);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeAuthPage, { once: true });
} else {
initializeAuthPage();
}
return {
request,
getSessionValue,

View File

@@ -32,6 +32,18 @@ test("home page loads auth entry points", async ({ page }) => {
await expect(page.getByLabel("Password").nth(1)).toBeVisible();
});
test("home document renders static auth markup without bootstrapping blazor", async ({ request }) => {
const response = await request.get("/");
expect(response.ok()).toBeTruthy();
const html = await response.text();
expect(html).not.toContain("Connecting...");
expect(html).toContain("Register or log in to join a campaign session.");
expect(html).not.toContain("_framework/blazor.web.js");
expect(html).not.toContain("<!--Blazor:");
expect(html).toContain("data-auth-page");
});
test("successful login transitions to play workspace", async ({ page, context }) => {
const username = `login-${Date.now()}`;
const password = "Password123";