diff --git a/README.md b/README.md index 00f462f..6bf37cb 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,8 @@ Gameplay capabilities now include: - Campaign management owner labels use account display names (no GUID fallback rendering) - Character edit flow supports unlinking from campaigns (owner/GM/admin) and assigning to any existing campaign via expanded campaign options - Campaign management supports character deletion by character owner or admin -- Shared top header control across workspace and admin views (consistent navigation/logout behavior) -- Admin menu navigation now directly targets `Play` or `Campaign Management` workspace screens +- Shared top header control across all authenticated workspace screens (play, campaign management, admin) +- Admin user management is integrated into workspace screen toggles (`Play`, `Campaign Management`, `Admin`) ## Prerequisites diff --git a/RpgRoller/Components/Pages/Home.Models.cs b/RpgRoller/Components/Pages/Home.Models.cs index e2a66a8..cfd5d9f 100644 --- a/RpgRoller/Components/Pages/Home.Models.cs +++ b/RpgRoller/Components/Pages/Home.Models.cs @@ -60,6 +60,5 @@ public enum HomeViewMode { Loading, Anonymous, - Workspace, - Admin + Workspace } diff --git a/RpgRoller/Components/Pages/Home.razor b/RpgRoller/Components/Pages/Home.razor index c653c61..9c47300 100644 --- a/RpgRoller/Components/Pages/Home.razor +++ b/RpgRoller/Components/Pages/Home.razor @@ -22,10 +22,6 @@ break; case HomeViewMode.Workspace: - - break; - - case HomeViewMode.Admin: - + break; } diff --git a/RpgRoller/Components/Pages/Home.razor.cs b/RpgRoller/Components/Pages/Home.razor.cs index 52aeace..c567613 100644 --- a/RpgRoller/Components/Pages/Home.razor.cs +++ b/RpgRoller/Components/Pages/Home.razor.cs @@ -22,19 +22,16 @@ public partial class Home try { _ = await ApiClient.RequestAsync("GET", "/api/me"); - WorkspaceScreenOverride = null; CurrentView = HomeViewMode.Workspace; ClearStatus(); } catch (ApiRequestException ex) when (ex.StatusCode == 401) { - WorkspaceScreenOverride = null; CurrentView = HomeViewMode.Anonymous; ClearStatus(); } catch (ApiRequestException ex) { - WorkspaceScreenOverride = null; CurrentView = HomeViewMode.Anonymous; SetStatus(ex.Message, true); } @@ -42,22 +39,6 @@ public partial class Home private Task OnLoggedInAsync() { - WorkspaceScreenOverride = null; - CurrentView = HomeViewMode.Workspace; - ClearStatus(); - return InvokeAsync(StateHasChanged); - } - - private Task OnAdminRequestedAsync() - { - CurrentView = HomeViewMode.Admin; - ClearStatus(); - return InvokeAsync(StateHasChanged); - } - - private Task OnWorkspaceRequestedAsync(string screen) - { - WorkspaceScreenOverride = NormalizeWorkspaceScreen(screen) ?? "play"; CurrentView = HomeViewMode.Workspace; ClearStatus(); return InvokeAsync(StateHasChanged); @@ -65,7 +46,6 @@ public partial class Home private Task OnLoggedOutAsync(string? message) { - WorkspaceScreenOverride = null; CurrentView = HomeViewMode.Anonymous; if (string.IsNullOrWhiteSpace(message)) { @@ -90,19 +70,7 @@ public partial class Home StatusIsError = false; } - private static string? NormalizeWorkspaceScreen(string? screen) - { - if (string.Equals(screen, "management", StringComparison.OrdinalIgnoreCase)) - return "management"; - - if (string.Equals(screen, "play", StringComparison.OrdinalIgnoreCase)) - return "play"; - - return null; - } - private HomeViewMode CurrentView { get; set; } = HomeViewMode.Loading; - private string? WorkspaceScreenOverride { get; set; } private string? StatusMessage { get; set; } private bool StatusIsError { get; set; } private bool HasInitialized { get; set; } diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor index 3b0d29d..baa2a99 100644 --- a/RpgRoller/Components/Pages/Workspace.razor +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -76,8 +76,7 @@ } - - @if (IsManagementScreen) + else if (IsManagementScreen) { } + else if (IsAdminScreen) + { +
+
+
+

User Management

+
+ @if (IsAdminDataLoading) + { +

Loading users...

+ } + else if (!IsCurrentUserAdmin) + { +

Admin role is required to manage users.

+ } + else if (AdminUsers.Count == 0) + { +

No users found.

+ } + else + { +
    + @foreach (var user in AdminUsers) + { +
  • +
    + @user.Username +

    @user.DisplayName

    +

    Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))

    +
    +
    + + +
    +
  • + } +
+ } +
+
+ } @if (Toasts.Count > 0) diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index 1478879..0edd7b5 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -22,18 +22,8 @@ public partial class Workspace : IAsyncDisposable private async Task InitializeAsync() { - var requestedScreen = NormalizeRequestedScreen(RequestedScreen); - if (requestedScreen is not null) - { - CurrentScreen = requestedScreen; - await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, CurrentScreen); - } - else - { - var storedScreen = await JS.InvokeAsync("rpgRollerApi.getSessionValue", ScreenSessionKey); - if (string.Equals(storedScreen, "management", StringComparison.OrdinalIgnoreCase)) - CurrentScreen = "management"; - } + var storedScreen = await JS.InvokeAsync("rpgRollerApi.getSessionValue", ScreenSessionKey); + CurrentScreen = NormalizeRequestedScreen(storedScreen) ?? ScreenPlay; var storedPanel = await JS.InvokeAsync("rpgRollerApi.getSessionValue", MobilePanelSessionKey); if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase)) @@ -126,11 +116,16 @@ public partial class Workspace : IAsyncDisposable User = me.User; ActiveCharacterId = me.ActiveCharacterId; + await EnsureScreenAccessAsync(); await ReloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId); await ReloadCharacterCampaignOptionsAsync(); await RefreshCampaignScopeAsync(); await SyncStateEventsAsync(); + + if (IsAdminScreen) + await EnsureAdminUsersLoadedAsync(); + return true; } @@ -234,9 +229,16 @@ public partial class Workspace : IAsyncDisposable private async Task SwitchScreenAsync(string screen) { - CurrentScreen = NormalizeRequestedScreen(screen) ?? "play"; + var targetScreen = NormalizeRequestedScreen(screen) ?? ScreenPlay; + if (string.Equals(targetScreen, ScreenAdmin, StringComparison.OrdinalIgnoreCase) && !IsCurrentUserAdmin) + targetScreen = ScreenPlay; + + CurrentScreen = targetScreen; IsScreenMenuOpen = false; - await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, CurrentScreen); + await PersistScreenPreferenceAsync(CurrentScreen); + + if (IsAdminScreen) + await EnsureAdminUsersLoadedAsync(); } private Task SwitchToPlayAsync() @@ -249,10 +251,112 @@ public partial class Workspace : IAsyncDisposable return SwitchScreenAsync("management"); } - private async Task OpenAdminAsync() + private Task SwitchToAdminAsync() { - IsScreenMenuOpen = false; - await AdminRequested.InvokeAsync(); + return SwitchScreenAsync(ScreenAdmin); + } + + private async Task EnsureScreenAccessAsync() + { + if (IsCurrentUserAdmin) + return; + + AdminUsers = []; + HasLoadedAdminUsers = false; + + if (!IsAdminScreen) + return; + + CurrentScreen = ScreenPlay; + await PersistScreenPreferenceAsync(CurrentScreen); + } + + private async Task EnsureAdminUsersLoadedAsync() + { + if (!IsCurrentUserAdmin || HasLoadedAdminUsers || IsAdminDataLoading) + return; + + 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 async Task ReloadAdminUsersAsync() + { + AdminUsers = (await ApiClient.RequestAsync>("GET", "/api/admin/users")) + .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 async Task SetMobilePanelAsync(string panel) @@ -533,15 +637,32 @@ public partial class Workspace : IAsyncDisposable private static string? NormalizeRequestedScreen(string? screen) { + if (string.Equals(screen, ScreenAdmin, StringComparison.OrdinalIgnoreCase)) + return ScreenAdmin; + if (string.Equals(screen, "management", StringComparison.OrdinalIgnoreCase)) return "management"; - if (string.Equals(screen, "play", StringComparison.OrdinalIgnoreCase)) - return "play"; + if (string.Equals(screen, ScreenPlay, StringComparison.OrdinalIgnoreCase)) + return ScreenPlay; return null; } + private async Task PersistScreenPreferenceAsync(string screen) + { + try + { + await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, screen); + } + catch (JSDisconnectedException) + { + } + catch (InvalidOperationException ex) when (IsStaticRenderInteropException(ex)) + { + } + } + private bool CanEditSkill(SkillSummary skill) { if (SelectedCampaign is null) @@ -752,6 +873,9 @@ public partial class Workspace : IAsyncDisposable EditCharacterInitialModel = new(); CreateCharacterFormVersion = 0; EditCharacterFormVersion = 0; + AdminUsers = []; + HasLoadedAdminUsers = false; + IsAdminDataLoading = false; Toasts.Clear(); } @@ -808,6 +932,7 @@ public partial class Workspace : IAsyncDisposable private List CharacterCampaignOptions { get; set; } = []; private List CampaignLog { get; set; } = []; private List Rulesets { get; set; } = []; + private List AdminUsers { get; set; } = []; private Guid? SelectedCharacterId { get; set; } private RollResult? LastRoll { get; set; } private List KnownUsernames { get; set; } = []; @@ -815,6 +940,8 @@ public partial class Workspace : IAsyncDisposable private bool IsMutating { get; set; } private bool IsCampaignDataLoading { get; set; } + private bool IsAdminDataLoading { get; set; } + private bool HasLoadedAdminUsers { get; set; } private bool HasHealthIssue { get; set; } private string HealthIssueMessage { get; set; } = "Retry to restore the API connection."; private List Toasts { get; } = []; @@ -839,12 +966,6 @@ public partial class Workspace : IAsyncDisposable [Parameter] public EventCallback LoggedOut { get; set; } - [Parameter] - public EventCallback AdminRequested { get; set; } - - [Parameter] - public string? RequestedScreen { get; set; } - private string? SelectedCampaignName => SelectedCampaign?.Name; private CharacterSummary? SelectedCharacter => @@ -862,14 +983,20 @@ public partial class Workspace : IAsyncDisposable private bool IsSelectedCampaignD6 => string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase); + private static bool HasAdminRole(AdminUserSummary user) + { + return user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase); + } + private List SelectedCharacterSkills => SelectedCampaign is null || !SelectedCharacterId.HasValue ? [] : SelectedCampaign.Skills.Where(skill => skill.CharacterId == SelectedCharacterId.Value).OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList(); private List SelectedCharacterSkillGroups => SelectedCampaign is null || !SelectedCharacterId.HasValue ? [] : SelectedCampaign.SkillGroups.Where(group => group.CharacterId == SelectedCharacterId.Value).OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList(); - private bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase); - private bool IsManagementScreen => !IsPlayScreen; + private bool IsPlayScreen => string.Equals(CurrentScreen, ScreenPlay, StringComparison.OrdinalIgnoreCase); + private bool IsManagementScreen => string.Equals(CurrentScreen, ScreenManagement, StringComparison.OrdinalIgnoreCase); + private bool IsAdminScreen => string.Equals(CurrentScreen, ScreenAdmin, StringComparison.OrdinalIgnoreCase); private IReadOnlyList HeaderMenuItems { get @@ -881,7 +1008,7 @@ public partial class Workspace : IAsyncDisposable }; if (IsCurrentUserAdmin) - items.Add(new AppHeaderMenuItem { Label = "Admin", IsActive = false, OnSelected = OpenAdminAsync }); + items.Add(new AppHeaderMenuItem { Label = "Admin", IsActive = IsAdminScreen, OnSelected = SwitchToAdminAsync }); return items; } @@ -903,6 +1030,9 @@ public partial class Workspace : IAsyncDisposable private string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app"; + private const string ScreenPlay = "play"; + private const string ScreenManagement = "management"; + private const string ScreenAdmin = "admin"; private const string ScreenSessionKey = "screen"; private const string CampaignSessionKey = "campaign"; private const string MobilePanelSessionKey = "play-panel";