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]
|
||||
|
||||
<!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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
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 = "";
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user