From abee1729c5d9133e91c4e1f96d31935548b3b7ae Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sun, 5 Apr 2026 00:08:46 +0200 Subject: [PATCH] Extract workspace admin coordinator --- README.md | 2 +- RpgRoller/Components/Pages/Workspace.razor.cs | 103 ++------------ .../Pages/WorkspaceAdminCoordinator.cs | 132 ++++++++++++++++++ tests/e2e/smoke.spec.js | 2 +- 4 files changed, 147 insertions(+), 92 deletions(-) create mode 100644 RpgRoller/Components/Pages/WorkspaceAdminCoordinator.cs diff --git a/README.md b/README.md index 2686c61..ee0b553 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Frontend: - `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/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/Pages/Home.Models.cs`: reusable `FormState` + page form models - `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor` diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index aa2a574..9a2c285 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -186,93 +186,11 @@ public partial class Workspace : IAsyncDisposable return SwitchScreenAsync(ScreenAdmin); } - private async Task EnsureAdminUsersLoadedAsync() - { - if (!IsCurrentUserAdmin || HasLoadedAdminUsers || IsAdminDataLoading) - return; + private Task EnsureAdminUsersLoadedAsync() => Admin.EnsureAdminUsersLoadedAsync(); - IsAdminDataLoading = true; - 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 Task ToggleAdminRoleAsync(AdminUserSummary user) => Admin.ToggleAdminRoleAsync(user); - private async Task ReloadAdminUsersAsync() - { - 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 roles = HasAdminRole(user) ? Array.Empty() : [UserRoles.Admin]; - _ = await ApiClient.RequestAsync( - "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("confirm", $"Delete user '{user.Username}'?"); - if (!confirmed) - return; - - IsMutating = true; - try - { - _ = await ApiClient.RequestAsync("DELETE", $"/api/admin/users/{user.Id}"); - await ReloadAdminUsersAsync(); - SetStatus("User deleted.", false); - } - catch (ApiRequestException ex) - { - SetStatus(ex.Message, true); - } - finally - { - IsMutating = false; - } - } + private Task DeleteUserAsync(AdminUserSummary user) => Admin.DeleteUserAsync(user); private async Task SetMobilePanelAsync(string panel) { @@ -970,14 +888,18 @@ public partial class Workspace : IAsyncDisposable private bool CanDeleteSelectedCampaign => State.CanDeleteSelectedCampaign; 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 IsManagementScreen => State.IsManagementScreen; 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 WorkspaceSessionCoordinator Session => m_Session ??= new( State, @@ -1021,6 +943,7 @@ public partial class Workspace : IAsyncDisposable private const string MobilePanelSessionKey = "play-panel"; private const int CampaignLogWindowSize = 25; + private WorkspaceAdminCoordinator? m_Admin; private WorkspaceFeedbackService? m_Feedback; private WorkspaceSessionCoordinator? m_Session; } diff --git a/RpgRoller/Components/Pages/WorkspaceAdminCoordinator.cs b/RpgRoller/Components/Pages/WorkspaceAdminCoordinator.cs new file mode 100644 index 0000000..73cef30 --- /dev/null +++ b/RpgRoller/Components/Pages/WorkspaceAdminCoordinator.cs @@ -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 stopStateEventsAsync, + Func 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 roles = HasAdminRole(user) ? Array.Empty() : [UserRoles.Admin]; + _ = await m_ApiClient.RequestAsync( + "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("confirm", $"Delete user '{user.Username}'?"); + if (!confirmed) + return; + + m_State.IsMutating = true; + try + { + _ = await m_ApiClient.RequestAsync("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 m_OnLoggedOutAsync; + private readonly WorkspaceState m_State; + private readonly Func m_StopStateEventsAsync; + private readonly WorkspaceQueryService m_WorkspaceQuery; +} diff --git a/tests/e2e/smoke.spec.js b/tests/e2e/smoke.spec.js index 046a1cc..e1f31d2 100644 --- a/tests/e2e/smoke.spec.js +++ b/tests/e2e/smoke.spec.js @@ -58,7 +58,7 @@ test("Rolemaster open-ended roll detail renders specialized dice chips", async ( await page.goto("/"); 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(); await expect(logEntry).toBeVisible();