Remove workspace session-token coupling

This commit is contained in:
2026-05-03 00:08:47 +02:00
parent 1f19bf7bfd
commit 231b0ac9a0
5 changed files with 58 additions and 76 deletions

View File

@@ -17,7 +17,7 @@
<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">
@if (UseInteractiveApp) @if (UseInteractiveApp)
{ {
<HeadOutlet @rendermode="InteractiveServer"/> <HeadOutlet @rendermode="@(new InteractiveServerRenderMode(prerender: false))"/>
} }
</head> </head>
<body> <body>
@@ -27,7 +27,7 @@
} }
else else
{ {
<Routes @rendermode="InteractiveServer"/> <Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))"/>
} }
<script src="js/rpgroller-api.js"></script> <script src="js/rpgroller-api.js"></script>
@if (UseInteractiveApp) @if (UseInteractiveApp)
@@ -92,4 +92,4 @@ else
return value.Count > 0 ? value[0] : null; return value.Count > 0 ? value[0] : null;
} }
} }

View File

@@ -1,81 +1,70 @@
using Microsoft.AspNetCore.WebUtilities;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Components; namespace RpgRoller.Components;
public sealed class WorkspaceQueryService(IGameService gameService, WorkspaceSessionTokenAccessor sessionTokenAccessor) public sealed class WorkspaceQueryService(RpgRollerApiClient apiClient)
{ {
public Task<MeResponse> GetMeAsync() public Task<MeResponse> GetMeAsync()
{ {
return Task.FromResult(GetValue(gameService.GetMe(GetRequiredSessionToken()))); return apiClient.RequestAsync<MeResponse>("GET", "/api/me");
} }
public Task<IReadOnlyList<RulesetDefinition>> GetRulesetsAsync() public async Task<IReadOnlyList<RulesetDefinition>> GetRulesetsAsync()
{ {
return Task.FromResult(gameService.GetRulesets()); return await apiClient.RequestAsync<RulesetDefinition[]>("GET", "/api/rulesets");
} }
public Task<IReadOnlyList<CampaignSummary>> GetCampaignsAsync() public async Task<IReadOnlyList<CampaignSummary>> GetCampaignsAsync()
{ {
return Task.FromResult(GetValue(gameService.GetCampaigns(GetRequiredSessionToken()))); return await apiClient.RequestAsync<CampaignSummary[]>("GET", "/api/campaigns");
} }
public Task<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptionsAsync() public async Task<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptionsAsync()
{ {
return Task.FromResult(GetValue(gameService.GetCharacterCampaignOptions(GetRequiredSessionToken()))); return await apiClient.RequestAsync<CampaignOption[]>("GET", "/api/campaigns/options");
} }
public Task<CampaignRoster> GetCampaignAsync(Guid campaignId) public Task<CampaignRoster> GetCampaignAsync(Guid campaignId)
{ {
return Task.FromResult(GetValue(gameService.GetCampaign(GetRequiredSessionToken(), campaignId))); return apiClient.RequestAsync<CampaignRoster>("GET", $"/api/campaigns/{campaignId:D}");
} }
public Task<IReadOnlyList<string>> GetUsernamesAsync() public async Task<IReadOnlyList<string>> GetUsernamesAsync()
{ {
return Task.FromResult(GetValue(gameService.GetUsernames(GetRequiredSessionToken()))); return await apiClient.RequestAsync<string[]>("GET", "/api/users/usernames");
} }
public Task<CharacterSheet> GetCharacterSheetAsync(Guid characterId) public Task<CharacterSheet> GetCharacterSheetAsync(Guid characterId)
{ {
return Task.FromResult(GetValue(gameService.GetCharacterSheet(GetRequiredSessionToken(), characterId))); return apiClient.RequestAsync<CharacterSheet>("GET", $"/api/characters/{characterId:D}/sheet");
} }
public Task<IReadOnlyList<CampaignLogEntry>> GetCampaignLogAsync(Guid campaignId) public async Task<IReadOnlyList<CampaignLogEntry>> GetCampaignLogAsync(Guid campaignId)
{ {
return Task.FromResult(GetValue(gameService.GetCampaignLog(GetRequiredSessionToken(), campaignId))); return await apiClient.RequestAsync<CampaignLogEntry[]>("GET", $"/api/campaigns/{campaignId:D}/log");
} }
public Task<CampaignLogPage> GetCampaignLogPageAsync(Guid campaignId, Guid? afterRollId = null, int? limit = null) public Task<CampaignLogPage> GetCampaignLogPageAsync(Guid campaignId, Guid? afterRollId = null, int? limit = null)
{ {
return Task.FromResult(GetValue(gameService.GetCampaignLogPage(GetRequiredSessionToken(), campaignId, afterRollId, limit))); var query = new Dictionary<string, string?>();
if (afterRollId.HasValue)
query["afterRollId"] = afterRollId.Value.ToString("D");
if (limit.HasValue)
query["limit"] = limit.Value.ToString();
var path = QueryHelpers.AddQueryString($"/api/campaigns/{campaignId:D}/log/page", query);
return apiClient.RequestAsync<CampaignLogPage>("GET", path);
} }
public Task<CampaignRollDetail> GetRollDetailAsync(Guid rollId) public Task<CampaignRollDetail> GetRollDetailAsync(Guid rollId)
{ {
return Task.FromResult(GetValue(gameService.GetRollDetail(GetRequiredSessionToken(), rollId))); return apiClient.RequestAsync<CampaignRollDetail>("GET", $"/api/rolls/{rollId:D}");
} }
public Task<IReadOnlyList<AdminUserSummary>> GetAdminUsersAsync() public async Task<IReadOnlyList<AdminUserSummary>> GetAdminUsersAsync()
{ {
return Task.FromResult(GetValue(gameService.GetUsers(GetRequiredSessionToken()))); return await apiClient.RequestAsync<AdminUserSummary[]>("GET", "/api/admin/users");
}
private string GetRequiredSessionToken()
{
return sessionTokenAccessor.GetRequiredSessionToken();
}
private static T GetValue<T>(ServiceResult<T> result)
{
if (result.Succeeded)
return result.Value!;
throw ToApiRequestException(result.Error!);
}
private static ApiRequestException ToApiRequestException(ServiceError error)
{
var statusCode = error.Code == "unauthorized" ? 401 : 400;
return new(statusCode, error.Message, error.Code);
} }
} }

View File

@@ -1,33 +0,0 @@
using RpgRoller.Api;
namespace RpgRoller.Components;
public sealed class WorkspaceSessionTokenAccessor
{
public WorkspaceSessionTokenAccessor(IHttpContextAccessor httpContextAccessor)
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null)
return;
if (httpContext.Items.TryGetValue(SessionTokenItemKey, out var storedToken) && storedToken is string sessionToken && !string.IsNullOrWhiteSpace(sessionToken))
{
m_SessionToken = sessionToken;
return;
}
if (httpContext.TryReadSessionTokenFromCookie(out sessionToken))
m_SessionToken = sessionToken;
}
public string GetRequiredSessionToken()
{
if (!string.IsNullOrWhiteSpace(m_SessionToken))
return m_SessionToken;
throw new ApiRequestException(401, "You must be logged in.");
}
private const string SessionTokenItemKey = "__rpgroller.session-token";
private readonly string? m_SessionToken;
}

View File

@@ -13,9 +13,7 @@ builder.Services.AddResponseCompression(options =>
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]); options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]);
}); });
builder.Services.ConfigureHttpJsonOptions(options => RpgRollerJson.Configure(options.SerializerOptions)); builder.Services.ConfigureHttpJsonOptions(options => RpgRollerJson.Configure(options.SerializerOptions));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<RpgRollerApiClient>(); builder.Services.AddScoped<RpgRollerApiClient>();
builder.Services.AddScoped<WorkspaceSessionTokenAccessor>();
builder.Services.AddScoped<WorkspaceQueryService>(); builder.Services.AddScoped<WorkspaceQueryService>();
var app = builder.Build(); var app = builder.Build();

View File

@@ -44,6 +44,34 @@ 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 }) => {
const username = `doc-auth-${Date.now()}`;
const password = "Password123";
await postJson(request, "/api/auth/register", {
username,
password,
displayName: "Document Auth"
});
const loginResponse = await request.post("/api/auth/login", {
data: {
username,
password
}
});
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");
});
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";