From 2d2ed561cc6df0f7192ec54bc004c41e6667e191 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 2 May 2026 23:31:49 +0200 Subject: [PATCH] Isolate anonymous auth page from Blazor --- RpgRoller/Components/App.razor | 64 ++++++- RpgRoller/Components/Pages/Home.razor | 27 +-- RpgRoller/Components/Pages/Home.razor.cs | 80 ++------- .../Pages/HomeControls/StaticAuthPage.razor | 55 ++++++ RpgRoller/wwwroot/js/rpgroller-api.js | 158 ++++++++++++++++++ tests/e2e/smoke.spec.js | 12 ++ 6 files changed, 299 insertions(+), 97 deletions(-) create mode 100644 RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor diff --git a/RpgRoller/Components/App.razor b/RpgRoller/Components/App.razor index db04244..bbea768 100644 --- a/RpgRoller/Components/App.razor +++ b/RpgRoller/Components/App.razor @@ -1,3 +1,6 @@ +@using RpgRoller.Api +@using RpgRoller.Components.Pages.HomeControls +@using RpgRoller.Services @attribute [ExcludeFromCodeCoverage] @@ -12,20 +15,64 @@ - + @if (UseInteractiveApp) + { + + } - +@if (UseStaticAuthPage) +{ + +} +else +{ + +} - +@if (UseInteractiveApp) +{ + +} @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 @@ } } -} \ No newline at end of file + 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; + } + +} diff --git a/RpgRoller/Components/Pages/Home.razor b/RpgRoller/Components/Pages/Home.razor index a2ed9d7..efa8156 100644 --- a/RpgRoller/Components/Pages/Home.razor +++ b/RpgRoller/Components/Pages/Home.razor @@ -1,27 +1,2 @@ @page "/" -@using RpgRoller.Components.Pages.HomeControls - -@switch (CurrentView) -{ - case HomeViewMode.Loading: -
-
-

RpgRoller

-

Connecting...

-
-
- break; - - case HomeViewMode.Anonymous: -
- -
- break; - - case HomeViewMode.Workspace: - - break; -} \ No newline at end of file + diff --git a/RpgRoller/Components/Pages/Home.razor.cs b/RpgRoller/Components/Pages/Home.razor.cs index da9225e..e94fb2b 100644 --- a/RpgRoller/Components/Pages/Home.razor.cs +++ b/RpgRoller/Components/Pages/Home.razor.cs @@ -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("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 + { + ["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!; } \ No newline at end of file diff --git a/RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor b/RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor new file mode 100644 index 0000000..5b58045 --- /dev/null +++ b/RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor @@ -0,0 +1,55 @@ +
+
+

RpgRoller

+

Register or log in to join a campaign session.

+ +
+
+

Register

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

Login

+ +
+ + + + + + + + + +
+
+
+
+
+ +@code { + [Parameter] + public string? StatusMessage { get; set; } + + [Parameter] + public bool StatusIsError { get; set; } +} diff --git a/RpgRoller/wwwroot/js/rpgroller-api.js b/RpgRoller/wwwroot/js/rpgroller-api.js index 64a9fe0..ce4a13f 100644 --- a/RpgRoller/wwwroot/js/rpgroller-api.js +++ b/RpgRoller/wwwroot/js/rpgroller-api.js @@ -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, diff --git a/tests/e2e/smoke.spec.js b/tests/e2e/smoke.spec.js index cf5df10..c8268f7 100644 --- a/tests/e2e/smoke.spec.js +++ b/tests/e2e/smoke.spec.js @@ -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("