Refactor frontend entry to login and play routes

This commit is contained in:
2026-05-04 20:23:53 +02:00
parent a7f6163c4b
commit b9fba1bbbc
10 changed files with 97 additions and 49 deletions

View File

@@ -3,15 +3,41 @@ namespace RpgRoller.Tests;
public sealed class FrontendHostTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory) public sealed class FrontendHostTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
{ {
[Fact] [Fact]
public async Task RootPath_ServesBlazorFrontendShell() public async Task RootPath_RedirectsToLogin_WhenAnonymous()
{ {
using var factory = CreateFactory(1); using var factory = CreateFactory(1);
using var client = factory.CreateClient(new() { AllowAutoRedirect = false }); using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
var response = await client.GetAsync("/"); var response = await client.GetAsync("/");
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal("/login", response.Headers.Location?.OriginalString);
}
[Fact]
public async Task RootPath_RedirectsToPlay_WhenAuthenticated()
{
using var factory = CreateFactory(1);
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(client, "alice", "Password123", "Alice");
await LoginAsync(client, "alice", "Password123");
var response = await client.GetAsync("/");
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal("/play", response.Headers.Location?.OriginalString);
}
[Fact]
public async Task LoginPath_ServesStaticAuthMarkup()
{
using var factory = CreateFactory(1);
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
var response = await client.GetAsync("/login");
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var html = await response.Content.ReadAsStringAsync(); var html = await response.Content.ReadAsStringAsync();
Assert.Contains("_framework/blazor.web.js", html); Assert.Contains("Register or log in to join a campaign session.", html);
Assert.Contains("Connecting...", html); Assert.Contains("data-auth-page", html);
Assert.DoesNotContain("_framework/blazor.web.js", html);
} }
} }

View File

@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Http.HttpResults;
using RpgRoller.Services;
namespace RpgRoller.Api;
public static class FrontendEntryEndpoints
{
public static void MapFrontendEntryEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/", RedirectRootRequest);
}
private static RedirectHttpResult RedirectRootRequest(HttpContext context, IGameService game)
{
var redirectPath = context.TryReadSessionTokenFromCookie(out var sessionToken) &&
game.GetUserBySession(sessionToken) is not null
? "/play"
: "/login";
return TypedResults.Redirect(context.Request.PathBase.Add(redirectPath).Value!);
}
}

View File

@@ -1,6 +1,4 @@
@using RpgRoller.Api
@using RpgRoller.Components.Pages.HomeControls @using RpgRoller.Components.Pages.HomeControls
@using RpgRoller.Services
@attribute [ExcludeFromCodeCoverage] @attribute [ExcludeFromCodeCoverage]
<!DOCTYPE html> <!DOCTYPE html>
@@ -38,32 +36,18 @@ else
</html> </html>
@code { @code {
[Inject] private IGameService GameService { get; set; } = null!;
[CascadingParameter] private HttpContext? HttpContext { get; set; } [CascadingParameter] private HttpContext? HttpContext { get; set; }
private bool UseInteractiveApp => !UseStaticAuthPage; private bool UseInteractiveApp => !UseStaticAuthPage;
private bool UseStaticAuthPage => IsRootRequest && !HasAuthenticatedSession; private bool UseStaticAuthPage => IsLoginRequest;
private bool IsRootRequest private bool IsLoginRequest
{ {
get get
{ {
var path = HttpContext?.Request.Path.Value; var path = HttpContext?.Request.Path.Value;
return string.IsNullOrWhiteSpace(path) || string.Equals(path, "/", StringComparison.Ordinal); return string.Equals(path, "/login", 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;
} }
} }
@@ -85,7 +69,7 @@ else
private string? ReadAuthQueryValue(string key) private string? ReadAuthQueryValue(string key)
{ {
if (!UseStaticAuthPage || HttpContext is null) if (!IsLoginRequest || HttpContext is null)
return null; return null;
var value = HttpContext.Request.Query[key]; var value = HttpContext.Request.Query[key];

View File

@@ -0,0 +1 @@
@page "/login"

View File

@@ -1,2 +1,2 @@
@page "/" @page "/play"
<Workspace LoggedOut="OnLoggedOutAsync"/> <Workspace LoggedOut="OnLoggedOutAsync"/>

View File

@@ -5,13 +5,13 @@ using Microsoft.AspNetCore.WebUtilities;
namespace RpgRoller.Components.Pages; namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public partial class Home public partial class PlayPage
{ {
private Task OnLoggedOutAsync(string? message) private Task OnLoggedOutAsync(string? message)
{ {
if (string.IsNullOrWhiteSpace(message)) if (string.IsNullOrWhiteSpace(message))
{ {
Navigation.NavigateTo("/", forceLoad: true); Navigation.NavigateTo("/login", forceLoad: true);
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -21,7 +21,7 @@ public partial class Home
["kind"] = message.Contains("expired", StringComparison.OrdinalIgnoreCase) ? "error" : "success" ["kind"] = message.Contains("expired", StringComparison.OrdinalIgnoreCase) ? "error" : "success"
}; };
Navigation.NavigateTo(QueryHelpers.AddQueryString("/", query), forceLoad: true); Navigation.NavigateTo(QueryHelpers.AddQueryString("/login", query), forceLoad: true);
return Task.CompletedTask; return Task.CompletedTask;
} }

View File

@@ -35,6 +35,7 @@ app.UseResponseCompression();
app.UseAntiforgery(); app.UseAntiforgery();
app.MapRpgRollerApi(); app.MapRpgRollerApi();
app.MapFrontendEntryEndpoints();
app.MapStaticAssets(); app.MapStaticAssets();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode(); app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run(); app.Run();

View File

@@ -366,7 +366,7 @@ window.rpgRollerApi = (() => {
} }
if (formType === "login") { if (formType === "login") {
window.location.assign(toAppUrl("/")); window.location.assign(toAppUrl("/play"));
return; return;
} }

View File

@@ -16,7 +16,7 @@ The change is complete when a human can run the app, open `/`, observe the corre
- [x] (2026-05-04 17:52Z) Reviewed `POSTMORTEM.md`, the current app shell, workspace routing behavior, and the existing host and Playwright tests to define the rewrite around real routes instead of `sessionStorage` screen switching. - [x] (2026-05-04 17:52Z) Reviewed `POSTMORTEM.md`, the current app shell, workspace routing behavior, and the existing host and Playwright tests to define the rewrite around real routes instead of `sessionStorage` screen switching.
- [x] (2026-05-04 17:52Z) Updated `README.md` so it accurately describes the current architecture and the approved rewrite direction. - [x] (2026-05-04 17:52Z) Updated `README.md` so it accurately describes the current architecture and the approved rewrite direction.
- [ ] Implement a server-side entry redirect for `/` and move the anonymous auth experience to `/login`. - [x] (2026-05-04 18:29Z) Implemented a host-level `/` redirect to `/login` or `/play`, moved the static auth document to `/login`, switched login/logout targets to `/play` and `/login`, and updated the root-path host and smoke coverage to the new contract.
- [ ] Introduce real authenticated routes for `/play`, `/campaigns`, and `/admin` while preserving current behavior. - [ ] Introduce real authenticated routes for `/play`, `/campaigns`, and `/admin` while preserving current behavior.
- [ ] Remove `screen` as a `sessionStorage` routing mechanism and replace menu actions with URL navigation. - [ ] Remove `screen` as a `sessionStorage` routing mechanism and replace menu actions with URL navigation.
- [ ] Split the large `Workspace` render tree so play, campaign management, and admin each own a smaller subtree. - [ ] Split the large `Workspace` render tree so play, campaign management, and admin each own a smaller subtree.
@@ -34,6 +34,15 @@ The change is complete when a human can run the app, open `/`, observe the corre
- Observation: the current host test also encodes an outdated assumption about `/`. - Observation: the current host test also encodes an outdated assumption about `/`.
Evidence: `RpgRoller.Tests/Api/FrontendHostTests.cs` currently asserts that `GET /` returns HTTP 200 and a Blazor shell containing `_framework/blazor.web.js`. Evidence: `RpgRoller.Tests/Api/FrontendHostTests.cs` currently asserts that `GET /` returns HTTP 200 and a Blazor shell containing `_framework/blazor.web.js`.
- Observation: `MapRazorComponents<App>()` does not serve the static `/login` document unless a matching component route exists, even though `App.razor` itself renders the static auth markup outside the interactive router.
Evidence: the first Milestone 1 host test run returned HTTP 404 for `GET /login` until a minimal `RpgRoller/Components/Pages/LoginPage.razor` with `@page "/login"` was added.
- Observation: the repository-wide backend suite currently contains a missing-fixture failure unrelated to the route-first rewrite.
Evidence: `dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings` failed in `HostingCoverageTests.InitializeRpgRollerState_MigratesCopiedDevelopmentDatabaseAndPreservesD6Rolling` because `RpgRoller/App_Data/rpgroller.development.db` is not present in the worktree.
- Observation: the locally installed Snap Firefox build on this machine does not complete Playwrights Firefox control handshake.
Evidence: Playwright launched `/usr/bin/firefox` with `-juggler-pipe` and stalled before page automation began, so Milestone 1 browser verification was completed with `geckodriver` plus Selenium against the same temporary app instance instead.
## Decision Log ## Decision Log
- Decision: implement the approved route-first approach rather than continuing to add localized mitigations inside the current `/` workspace shell. - Decision: implement the approved route-first approach rather than continuing to add localized mitigations inside the current `/` workspace shell.
@@ -60,6 +69,8 @@ The change is complete when a human can run the app, open `/`, observe the corre
At plan creation time, the repository has an updated README and a concrete implementation plan, but no code for the route-first rewrite has been started yet. The immediate risk is not uncertainty about direction; it is carrying old assumptions about `/`, `Home.razor`, and `sessionStorage`-based screen switching into the first code changes. The milestones below are written to make those assumptions explicit and retire them in an observable order. At plan creation time, the repository has an updated README and a concrete implementation plan, but no code for the route-first rewrite has been started yet. The immediate risk is not uncertainty about direction; it is carrying old assumptions about `/`, `Home.razor`, and `sessionStorage`-based screen switching into the first code changes. The milestones below are written to make those assumptions explicit and retire them in an observable order.
After Milestone 1, the dual-purpose `/` entry point is gone. Anonymous requests to `/` are now redirected before Blazor renders, the static auth document lives at `/login`, and successful login lands on `/play`. The main residual risk is that the authenticated shell is still monolithic behind the new `/play` route, so later milestones still need to replace in-memory screen switching with real route ownership.
This section must be updated after each major milestone. When the implementation is complete, summarize which parts of the old workspace architecture were fully removed, which compatibility constraints remain, and whether the final startup path still depends on any multi-batch structural rendering. This section must be updated after each major milestone. When the implementation is complete, summarize which parts of the old workspace architecture were fully removed, which compatibility constraints remain, and whether the final startup path still depends on any multi-batch structural rendering.
## Context and Orientation ## Context and Orientation

View File

@@ -23,8 +23,9 @@ async function registerAndLogin(request, username, displayName) {
} }
test("home page loads auth entry points", async ({ page }) => { test("home page loads auth entry points", async ({ page }) => {
await page.goto("/"); await page.goto("/play");
await expect(page).toHaveURL(/\/login$/);
await expect(page.locator("h1")).toContainText("RpgRoller"); await expect(page.locator("h1")).toContainText("RpgRoller");
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Login" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Login" })).toBeVisible();
@@ -32,8 +33,14 @@ test("home page loads auth entry points", async ({ page }) => {
await expect(page.getByLabel("Password").nth(1)).toBeVisible(); await expect(page.getByLabel("Password").nth(1)).toBeVisible();
}); });
test("home document renders static auth markup without bootstrapping blazor", async ({ request }) => { test("root document redirects anonymous users to login", async ({ request }) => {
const response = await request.get("/"); const response = await request.get("/", { maxRedirects: 0 });
expect(response.status()).toBe(302);
expect(response.headers()["location"]).toBe("/login");
});
test("login document renders static auth markup without bootstrapping blazor", async ({ request }) => {
const response = await request.get("/login");
expect(response.ok()).toBeTruthy(); expect(response.ok()).toBeTruthy();
const html = await response.text(); const html = await response.text();
@@ -44,7 +51,7 @@ test("home document renders static auth markup without bootstrapping blazor", as
expect(html).toContain("data-auth-page"); expect(html).toContain("data-auth-page");
}); });
test("authenticated home document avoids prerendered workspace shell", async ({ request }) => { test("authenticated root document redirects to play", async ({ request }) => {
const username = `doc-auth-${Date.now()}`; const username = `doc-auth-${Date.now()}`;
const password = "Password123"; const password = "Password123";
@@ -62,14 +69,9 @@ test("authenticated home document avoids prerendered workspace shell", async ({
}); });
expect(loginResponse.ok()).toBeTruthy(); expect(loginResponse.ok()).toBeTruthy();
const response = await request.get("/"); const response = await request.get("/", { maxRedirects: 0 });
expect(response.ok()).toBeTruthy(); expect(response.status()).toBe(302);
expect(response.headers()["location"]).toBe("/play");
const html = await response.text();
expect(html).toContain("_framework/blazor.web.js");
expect(html).not.toContain("Register or log in to join a campaign session.");
expect(html).not.toContain("Loading user...");
expect(html).not.toContain("Offline fallback");
}); });
test("successful login transitions to play workspace", async ({ page, context }) => { test("successful login transitions to play workspace", async ({ page, context }) => {
@@ -82,11 +84,12 @@ test("successful login transitions to play workspace", async ({ page, context })
displayName: "Login Flow" displayName: "Login Flow"
}); });
await page.goto("/"); await page.goto("/login");
await page.locator("#login-username").fill(username); await page.locator("#login-username").fill(username);
await page.locator("#login-password").fill(password); await page.locator("#login-password").fill(password);
await page.getByRole("button", { name: "Login" }).click(); await page.getByRole("button", { name: "Login" }).click();
await expect(page).toHaveURL(/\/play$/);
await expect(page.getByText("Campaign Log")).toBeVisible(); await expect(page.getByText("Campaign Log")).toBeVisible();
await expect(page.locator("#login-username")).toHaveCount(0); await expect(page.locator("#login-username")).toHaveCount(0);
}); });
@@ -166,7 +169,7 @@ test("workspace stays usable when input controls are DOM-wrapped during mount",
} }
}); });
await page.goto("/"); await page.goto("/play");
await expect(page.getByText("Campaign Log")).toBeVisible(); await expect(page.getByText("Campaign Log")).toBeVisible();
await expect(page.locator("#skill-filter-input")).toBeVisible(); await expect(page.locator("#skill-filter-input")).toBeVisible();
await expect(page.locator("#custom-roll-expression")).toBeVisible(); await expect(page.locator("#custom-roll-expression")).toBeVisible();
@@ -198,7 +201,7 @@ test("Rolemaster open-ended roll detail renders specialized dice chips", async (
await postJson(context.request, `/api/skills/${skill.id}/roll`, { visibility: "public" }); await postJson(context.request, `/api/skills/${skill.id}/roll`, { visibility: "public" });
await page.goto("/"); await page.goto("/play");
await expect(page.getByText("Campaign Log")).toBeVisible(); await expect(page.getByText("Campaign Log")).toBeVisible();
await expect(page.locator(".log-panel .log-entry").first()).toBeVisible(); await expect(page.locator(".log-panel .log-entry").first()).toBeVisible();
await expect(page.locator(".log-panel .log-event-badge")).toContainText(["Fumble"]); await expect(page.locator(".log-panel .log-event-badge")).toContainText(["Fumble"]);
@@ -244,7 +247,7 @@ test("Rolemaster automatic retry badge shows before detail expands", async ({ pa
expect(retriedRoll, "expected a retry-enabled Rolemaster roll within 10 attempts").not.toBeNull(); expect(retriedRoll, "expected a retry-enabled Rolemaster roll within 10 attempts").not.toBeNull();
await page.goto("/"); await page.goto("/play");
await expect(page.getByText("Campaign Log")).toBeVisible(); await expect(page.getByText("Campaign Log")).toBeVisible();
const retryEntry = page.locator(".log-panel .log-entry").filter({ hasText: "retry +" }).last(); const retryEntry = page.locator(".log-panel .log-entry").filter({ hasText: "retry +" }).last();
@@ -281,7 +284,7 @@ test("Rolemaster skill roll modal autofocuses, validates, and closes on escape o
rolemasterAutoRetry: true rolemasterAutoRetry: true
}); });
await page.goto("/"); await page.goto("/play");
await expect(page.getByText("Campaign Log")).toBeVisible(); await expect(page.getByText("Campaign Log")).toBeVisible();
const rollButton = page.getByRole("button", { name: "Roll Observation" }); const rollButton = page.getByRole("button", { name: "Roll Observation" });
@@ -332,7 +335,7 @@ test("newly rolled log entry auto-expands", async ({ page, context }) => {
allowFumble: true allowFumble: true
}); });
await page.goto("/"); await page.goto("/play");
await expect(page.getByText("Campaign Log")).toBeVisible(); await expect(page.getByText("Campaign Log")).toBeVisible();
await page.getByRole("button", { name: "Roll Stealth" }).click(); await page.getByRole("button", { name: "Roll Stealth" }).click();
@@ -355,7 +358,7 @@ test("custom roll composer keeps parse errors inline and records successful roll
campaignId: campaign.id campaignId: campaign.id
}); });
await page.goto("/"); await page.goto("/play");
await expect(page.getByText("Campaign Log")).toBeVisible(); await expect(page.getByText("Campaign Log")).toBeVisible();
const composer = page.locator(".custom-roll-composer"); const composer = page.locator(".custom-roll-composer");
@@ -402,7 +405,7 @@ test("Rolemaster UI exposes conditional create and edit fields", async ({ page,
rolemasterAutoRetry: true rolemasterAutoRetry: true
}); });
await page.goto("/"); await page.goto("/play");
await expect(page.locator("#workspace-screen-menu-button")).toBeVisible(); await expect(page.locator("#workspace-screen-menu-button")).toBeVisible();
await page.locator("#workspace-screen-menu-button").click(); await page.locator("#workspace-screen-menu-button").click();