Remove workspace session-token coupling
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user