Refactor frontend entry to login and play routes
This commit is contained in:
@@ -3,15 +3,41 @@ namespace RpgRoller.Tests;
|
||||
public sealed class FrontendHostTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
|
||||
{
|
||||
[Fact]
|
||||
public async Task RootPath_ServesBlazorFrontendShell()
|
||||
public async Task RootPath_RedirectsToLogin_WhenAnonymous()
|
||||
{
|
||||
using var factory = CreateFactory(1);
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
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);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("_framework/blazor.web.js", html);
|
||||
Assert.Contains("Connecting...", html);
|
||||
Assert.Contains("Register or log in to join a campaign session.", html);
|
||||
Assert.Contains("data-auth-page", html);
|
||||
Assert.DoesNotContain("_framework/blazor.web.js", html);
|
||||
}
|
||||
}
|
||||
22
RpgRoller/Api/FrontendEntryEndpoints.cs
Normal file
22
RpgRoller/Api/FrontendEntryEndpoints.cs
Normal 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!);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
@using RpgRoller.Api
|
||||
@using RpgRoller.Components.Pages.HomeControls
|
||||
@using RpgRoller.Services
|
||||
@attribute [ExcludeFromCodeCoverage]
|
||||
|
||||
<!DOCTYPE html>
|
||||
@@ -38,32 +36,18 @@ else
|
||||
</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 UseStaticAuthPage => IsLoginRequest;
|
||||
|
||||
private bool IsRootRequest
|
||||
private bool IsLoginRequest
|
||||
{
|
||||
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;
|
||||
return string.Equals(path, "/login", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +69,7 @@ else
|
||||
|
||||
private string? ReadAuthQueryValue(string key)
|
||||
{
|
||||
if (!UseStaticAuthPage || HttpContext is null)
|
||||
if (!IsLoginRequest || HttpContext is null)
|
||||
return null;
|
||||
|
||||
var value = HttpContext.Request.Query[key];
|
||||
|
||||
1
RpgRoller/Components/Pages/LoginPage.razor
Normal file
1
RpgRoller/Components/Pages/LoginPage.razor
Normal file
@@ -0,0 +1 @@
|
||||
@page "/login"
|
||||
@@ -1,2 +1,2 @@
|
||||
@page "/"
|
||||
@page "/play"
|
||||
<Workspace LoggedOut="OnLoggedOutAsync"/>
|
||||
@@ -5,13 +5,13 @@ using Microsoft.AspNetCore.WebUtilities;
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class Home
|
||||
public partial class PlayPage
|
||||
{
|
||||
private Task OnLoggedOutAsync(string? message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
Navigation.NavigateTo("/", forceLoad: true);
|
||||
Navigation.NavigateTo("/login", forceLoad: true);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ public partial class Home
|
||||
["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;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ app.UseResponseCompression();
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapRpgRollerApi();
|
||||
app.MapFrontendEntryEndpoints();
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
||||
app.Run();
|
||||
|
||||
@@ -366,7 +366,7 @@ window.rpgRollerApi = (() => {
|
||||
}
|
||||
|
||||
if (formType === "login") {
|
||||
window.location.assign(toAppUrl("/"));
|
||||
window.location.assign(toAppUrl("/play"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
13
TASKS.md
13
TASKS.md
@@ -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) 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.
|
||||
- [ ] 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.
|
||||
@@ -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 `/`.
|
||||
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 Playwright’s 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: 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.
|
||||
|
||||
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.
|
||||
|
||||
## Context and Orientation
|
||||
|
||||
@@ -23,8 +23,9 @@ async function registerAndLogin(request, username, displayName) {
|
||||
}
|
||||
|
||||
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.getByRole("heading", { name: "Register" })).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();
|
||||
});
|
||||
|
||||
test("home document renders static auth markup without bootstrapping blazor", async ({ request }) => {
|
||||
const response = await request.get("/");
|
||||
test("root document redirects anonymous users to login", async ({ request }) => {
|
||||
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();
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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 password = "Password123";
|
||||
|
||||
@@ -62,14 +69,9 @@ test("authenticated home document avoids prerendered workspace shell", async ({
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
|
||||
const response = await request.get("/");
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
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");
|
||||
const response = await request.get("/", { maxRedirects: 0 });
|
||||
expect(response.status()).toBe(302);
|
||||
expect(response.headers()["location"]).toBe("/play");
|
||||
});
|
||||
|
||||
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"
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.goto("/login");
|
||||
await page.locator("#login-username").fill(username);
|
||||
await page.locator("#login-password").fill(password);
|
||||
await page.getByRole("button", { name: "Login" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/play$/);
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
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.locator("#skill-filter-input")).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 page.goto("/");
|
||||
await page.goto("/play");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
await expect(page.locator(".log-panel .log-entry").first()).toBeVisible();
|
||||
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();
|
||||
|
||||
await page.goto("/");
|
||||
await page.goto("/play");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.goto("/play");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
|
||||
const rollButton = page.getByRole("button", { name: "Roll Observation" });
|
||||
@@ -332,7 +335,7 @@ test("newly rolled log entry auto-expands", async ({ page, context }) => {
|
||||
allowFumble: true
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.goto("/play");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.goto("/play");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
|
||||
const composer = page.locator(".custom-roll-composer");
|
||||
@@ -402,7 +405,7 @@ test("Rolemaster UI exposes conditional create and edit fields", async ({ page,
|
||||
rolemasterAutoRetry: true
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.goto("/play");
|
||||
await expect(page.locator("#workspace-screen-menu-button")).toBeVisible();
|
||||
|
||||
await page.locator("#workspace-screen-menu-button").click();
|
||||
|
||||
Reference in New Issue
Block a user