Extract workspace admin coordinator

This commit is contained in:
2026-04-05 00:08:46 +02:00
parent b3cde614e7
commit abee1729c5
4 changed files with 147 additions and 92 deletions

View File

@@ -35,7 +35,7 @@ Frontend:
- `RpgRoller/Components/Pages/Home.razor`: minimal gateway page (loading/auth/workspace switch) - `RpgRoller/Components/Pages/Home.razor`: minimal gateway page (loading/auth/workspace switch)
- `RpgRoller/Components/Pages/Home.razor.cs`: single `Home` code-behind with only gateway/session-view orchestration - `RpgRoller/Components/Pages/Home.razor.cs`: single `Home` code-behind with only gateway/session-view orchestration
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI and workspace-specific state/logic - `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI and workspace-specific state/logic
- `RpgRoller/Components/Pages/WorkspaceState.cs`, `WorkspaceToast.cs`, `WorkspaceFeedbackService.cs`, and `WorkspaceSessionCoordinator.cs`: extracted workspace UI state, toast records, feedback/status handling, and session/bootstrap orchestration while `Workspace` remains the behavior owner - `RpgRoller/Components/Pages/WorkspaceState.cs`, `WorkspaceToast.cs`, `WorkspaceFeedbackService.cs`, `WorkspaceSessionCoordinator.cs`, and `WorkspaceAdminCoordinator.cs`: extracted workspace UI state, toast records, feedback/status handling, session/bootstrap orchestration, and admin user actions while `Workspace` remains the behavior owner
- `RpgRoller/Components/**/*.razor.cs`: component code-behind classes (state, handlers, parameters, injected dependencies); `.razor` files remain markup-focused - `RpgRoller/Components/**/*.razor.cs`: component code-behind classes (state, handlers, parameters, injected dependencies); `.razor` files remain markup-focused
- `RpgRoller/Components/Pages/Home.Models.cs`: reusable `FormState<TModel>` + page form models - `RpgRoller/Components/Pages/Home.Models.cs`: reusable `FormState<TModel>` + page form models
- `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor` - `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor`

View File

@@ -186,93 +186,11 @@ public partial class Workspace : IAsyncDisposable
return SwitchScreenAsync(ScreenAdmin); return SwitchScreenAsync(ScreenAdmin);
} }
private async Task EnsureAdminUsersLoadedAsync() private Task EnsureAdminUsersLoadedAsync() => Admin.EnsureAdminUsersLoadedAsync();
{
if (!IsCurrentUserAdmin || HasLoadedAdminUsers || IsAdminDataLoading)
return;
IsAdminDataLoading = true; private Task ToggleAdminRoleAsync(AdminUserSummary user) => Admin.ToggleAdminRoleAsync(user);
try
{
await ReloadAdminUsersAsync();
}
catch (ApiRequestException ex) when (ex.StatusCode == 401)
{
ClearAuthenticatedState();
await StopStateEventsAsync();
await LoggedOut.InvokeAsync("Session expired. Please log in again.");
}
catch (ApiRequestException ex)
{
SetStatus(ex.Message, true);
}
finally
{
IsAdminDataLoading = false;
}
}
private async Task ReloadAdminUsersAsync() private Task DeleteUserAsync(AdminUserSummary user) => Admin.DeleteUserAsync(user);
{
AdminUsers = (await WorkspaceQuery.GetAdminUsersAsync())
.OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase)
.ToList();
HasLoadedAdminUsers = true;
}
private async Task ToggleAdminRoleAsync(AdminUserSummary user)
{
if (IsMutating || User is null || !IsCurrentUserAdmin || user.Id == User.Id)
return;
IsMutating = true;
try
{
IReadOnlyList<string> roles = HasAdminRole(user) ? Array.Empty<string>() : [UserRoles.Admin];
_ = await ApiClient.RequestAsync<AdminUserSummary>(
"PUT",
$"/api/admin/users/{user.Id}/roles",
new UpdateUserRolesRequest(roles));
await ReloadAdminUsersAsync();
SetStatus("User roles updated.", false);
}
catch (ApiRequestException ex)
{
SetStatus(ex.Message, true);
}
finally
{
IsMutating = false;
}
}
private async Task DeleteUserAsync(AdminUserSummary user)
{
if (IsMutating || User is null || !IsCurrentUserAdmin || user.Id == User.Id)
return;
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Delete user '{user.Username}'?");
if (!confirmed)
return;
IsMutating = true;
try
{
_ = await ApiClient.RequestAsync<bool>("DELETE", $"/api/admin/users/{user.Id}");
await ReloadAdminUsersAsync();
SetStatus("User deleted.", false);
}
catch (ApiRequestException ex)
{
SetStatus(ex.Message, true);
}
finally
{
IsMutating = false;
}
}
private async Task SetMobilePanelAsync(string panel) private async Task SetMobilePanelAsync(string panel)
{ {
@@ -970,14 +888,18 @@ public partial class Workspace : IAsyncDisposable
private bool CanDeleteSelectedCampaign => State.CanDeleteSelectedCampaign; private bool CanDeleteSelectedCampaign => State.CanDeleteSelectedCampaign;
private bool IsSelectedCampaignD6 => State.IsSelectedCampaignD6; private bool IsSelectedCampaignD6 => State.IsSelectedCampaignD6;
private static bool HasAdminRole(AdminUserSummary user)
{
return user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase);
}
private bool IsPlayScreen => State.IsPlayScreen; private bool IsPlayScreen => State.IsPlayScreen;
private bool IsManagementScreen => State.IsManagementScreen; private bool IsManagementScreen => State.IsManagementScreen;
private bool IsAdminScreen => State.IsAdminScreen; private bool IsAdminScreen => State.IsAdminScreen;
private WorkspaceAdminCoordinator Admin => m_Admin ??= new(
State,
Feedback,
JS,
ApiClient,
WorkspaceQuery,
ClearAuthenticatedState,
StopStateEventsAsync,
message => LoggedOut.InvokeAsync(message));
private WorkspaceFeedbackService Feedback => m_Feedback ??= new(State, () => InvokeAsync(StateHasChanged)); private WorkspaceFeedbackService Feedback => m_Feedback ??= new(State, () => InvokeAsync(StateHasChanged));
private WorkspaceSessionCoordinator Session => m_Session ??= new( private WorkspaceSessionCoordinator Session => m_Session ??= new(
State, State,
@@ -1021,6 +943,7 @@ public partial class Workspace : IAsyncDisposable
private const string MobilePanelSessionKey = "play-panel"; private const string MobilePanelSessionKey = "play-panel";
private const int CampaignLogWindowSize = 25; private const int CampaignLogWindowSize = 25;
private WorkspaceAdminCoordinator? m_Admin;
private WorkspaceFeedbackService? m_Feedback; private WorkspaceFeedbackService? m_Feedback;
private WorkspaceSessionCoordinator? m_Session; private WorkspaceSessionCoordinator? m_Session;
} }

View File

@@ -0,0 +1,132 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.JSInterop;
using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class WorkspaceAdminCoordinator
{
public WorkspaceAdminCoordinator(
WorkspaceState state,
WorkspaceFeedbackService feedback,
IJSRuntime js,
RpgRollerApiClient apiClient,
WorkspaceQueryService workspaceQuery,
Action clearAuthenticatedState,
Func<Task> stopStateEventsAsync,
Func<string?, Task> onLoggedOutAsync)
{
m_State = state;
m_Feedback = feedback;
m_JS = js;
m_ApiClient = apiClient;
m_WorkspaceQuery = workspaceQuery;
m_ClearAuthenticatedState = clearAuthenticatedState;
m_StopStateEventsAsync = stopStateEventsAsync;
m_OnLoggedOutAsync = onLoggedOutAsync;
}
public async Task EnsureAdminUsersLoadedAsync()
{
if (!m_State.IsCurrentUserAdmin || m_State.HasLoadedAdminUsers || m_State.IsAdminDataLoading)
return;
m_State.IsAdminDataLoading = true;
try
{
await ReloadAdminUsersAsync();
}
catch (ApiRequestException ex) when (ex.StatusCode == 401)
{
m_ClearAuthenticatedState();
await m_StopStateEventsAsync();
await m_OnLoggedOutAsync("Session expired. Please log in again.");
}
catch (ApiRequestException ex)
{
m_Feedback.SetStatus(ex.Message, true);
}
finally
{
m_State.IsAdminDataLoading = false;
}
}
public async Task ToggleAdminRoleAsync(AdminUserSummary user)
{
if (m_State.IsMutating || m_State.User is null || !m_State.IsCurrentUserAdmin || user.Id == m_State.User.Id)
return;
m_State.IsMutating = true;
try
{
IReadOnlyList<string> roles = HasAdminRole(user) ? Array.Empty<string>() : [UserRoles.Admin];
_ = await m_ApiClient.RequestAsync<AdminUserSummary>(
"PUT",
$"/api/admin/users/{user.Id}/roles",
new UpdateUserRolesRequest(roles));
await ReloadAdminUsersAsync();
m_Feedback.SetStatus("User roles updated.", false);
}
catch (ApiRequestException ex)
{
m_Feedback.SetStatus(ex.Message, true);
}
finally
{
m_State.IsMutating = false;
}
}
public async Task DeleteUserAsync(AdminUserSummary user)
{
if (m_State.IsMutating || m_State.User is null || !m_State.IsCurrentUserAdmin || user.Id == m_State.User.Id)
return;
var confirmed = await m_JS.InvokeAsync<bool>("confirm", $"Delete user '{user.Username}'?");
if (!confirmed)
return;
m_State.IsMutating = true;
try
{
_ = await m_ApiClient.RequestAsync<bool>("DELETE", $"/api/admin/users/{user.Id}");
await ReloadAdminUsersAsync();
m_Feedback.SetStatus("User deleted.", false);
}
catch (ApiRequestException ex)
{
m_Feedback.SetStatus(ex.Message, true);
}
finally
{
m_State.IsMutating = false;
}
}
private async Task ReloadAdminUsersAsync()
{
m_State.AdminUsers = (await m_WorkspaceQuery.GetAdminUsersAsync())
.OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase)
.ToList();
m_State.HasLoadedAdminUsers = true;
}
private static bool HasAdminRole(AdminUserSummary user)
{
return user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase);
}
private readonly RpgRollerApiClient m_ApiClient;
private readonly Action m_ClearAuthenticatedState;
private readonly WorkspaceFeedbackService m_Feedback;
private readonly IJSRuntime m_JS;
private readonly Func<string?, Task> m_OnLoggedOutAsync;
private readonly WorkspaceState m_State;
private readonly Func<Task> m_StopStateEventsAsync;
private readonly WorkspaceQueryService m_WorkspaceQuery;
}

View File

@@ -58,7 +58,7 @@ test("Rolemaster open-ended roll detail renders specialized dice chips", async (
await page.goto("/"); await page.goto("/");
await expect(page.getByText("Campaign Log")).toBeVisible(); await expect(page.getByText("Campaign Log")).toBeVisible();
await expect(page.locator(".log-panel .log-event-badge")).toHaveText("Fumble"); await expect(page.locator(".log-panel .log-event-badge")).toContainText(["Fumble"]);
const logEntry = page.locator(".log-panel .log-entry-toggle").first(); const logEntry = page.locator(".log-panel .log-entry-toggle").first();
await expect(logEntry).toBeVisible(); await expect(logEntry).toBeVisible();