Isolate anonymous auth page from Blazor
This commit is contained in:
@@ -1,3 +1,6 @@
|
|||||||
|
@using RpgRoller.Api
|
||||||
|
@using RpgRoller.Components.Pages.HomeControls
|
||||||
|
@using RpgRoller.Services
|
||||||
@attribute [ExcludeFromCodeCoverage]
|
@attribute [ExcludeFromCodeCoverage]
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -12,20 +15,64 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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=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">
|
<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>
|
</head>
|
||||||
<body>
|
<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="js/rpgroller-api.js"></script>
|
||||||
<script src="_framework/blazor.web.js"></script>
|
@if (UseInteractiveApp)
|
||||||
|
{
|
||||||
|
<script src="_framework/blazor.web.js"></script>
|
||||||
|
}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
[Inject]
|
||||||
|
private IGameService GameService { get; set; } = null!;
|
||||||
|
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
private HttpContext? HttpContext { get; set; }
|
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
|
private string BaseHref
|
||||||
{
|
{
|
||||||
get
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +1,2 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@using RpgRoller.Components.Pages.HomeControls
|
<Workspace LoggedOut="OnLoggedOutAsync"/>
|
||||||
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,83 +1,29 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using RpgRoller.Contracts;
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
|
||||||
namespace RpgRoller.Components.Pages;
|
namespace RpgRoller.Components.Pages;
|
||||||
|
|
||||||
[ExcludeFromCodeCoverage]
|
[ExcludeFromCodeCoverage]
|
||||||
public partial class Home
|
public partial class Home
|
||||||
{
|
{
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
private Task OnLoggedOutAsync(string? message)
|
||||||
{
|
{
|
||||||
if (!firstRender || HasInitialized)
|
if (string.IsNullOrWhiteSpace(message))
|
||||||
return;
|
|
||||||
|
|
||||||
HasInitialized = true;
|
|
||||||
await InitializeAsync();
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task InitializeAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
_ = await ApiClient.RequestAsync<MeResponse>("GET", "/api/me");
|
Navigation.NavigateTo("/", forceLoad: true);
|
||||||
CurrentView = HomeViewMode.Workspace;
|
return Task.CompletedTask;
|
||||||
ClearStatus();
|
|
||||||
}
|
}
|
||||||
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
|
||||||
{
|
|
||||||
CurrentView = HomeViewMode.Anonymous;
|
|
||||||
ClearStatus();
|
|
||||||
}
|
|
||||||
catch (ApiRequestException ex)
|
|
||||||
{
|
|
||||||
CurrentView = HomeViewMode.Anonymous;
|
|
||||||
SetStatus(ex.Message, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task OnLoggedInAsync()
|
var query = new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
ClearStatus();
|
["message"] = message,
|
||||||
Navigation.NavigateTo("/", forceLoad: true);
|
["kind"] = message.Contains("expired", StringComparison.OrdinalIgnoreCase) ? "error" : "success"
|
||||||
|
};
|
||||||
|
|
||||||
|
Navigation.NavigateTo(QueryHelpers.AddQueryString("/", query), forceLoad: true);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task OnLoggedOutAsync(string? message)
|
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
||||||
{
|
|
||||||
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!;
|
|
||||||
}
|
}
|
||||||
55
RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor
Normal file
55
RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor
Normal 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; }
|
||||||
|
}
|
||||||
@@ -214,6 +214,164 @@ window.rpgRollerApi = (() => {
|
|||||||
element.value = "";
|
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 {
|
return {
|
||||||
request,
|
request,
|
||||||
getSessionValue,
|
getSessionValue,
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ 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 }) => {
|
||||||
|
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 }) => {
|
test("successful login transitions to play workspace", async ({ page, context }) => {
|
||||||
const username = `login-${Date.now()}`;
|
const username = `login-${Date.now()}`;
|
||||||
const password = "Password123";
|
const password = "Password123";
|
||||||
|
|||||||
Reference in New Issue
Block a user