Unify play management and admin screens in workspace

This commit is contained in:
2026-02-26 18:30:29 +01:00
parent 52e3ae8b0f
commit 54aabc6d8c
6 changed files with 216 additions and 71 deletions

View File

@@ -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

View File

@@ -60,6 +60,5 @@ public enum HomeViewMode
{
Loading,
Anonymous,
Workspace,
Admin
Workspace
}

View File

@@ -22,10 +22,6 @@
break;
case HomeViewMode.Workspace:
<Workspace LoggedOut="OnLoggedOutAsync" AdminRequested="OnAdminRequestedAsync" RequestedScreen="WorkspaceScreenOverride"/>
break;
case HomeViewMode.Admin:
<AdminHome LoggedOut="OnLoggedOutAsync" WorkspaceRequested="OnWorkspaceRequestedAsync"/>
<Workspace LoggedOut="OnLoggedOutAsync"/>
break;
}

View File

@@ -22,19 +22,16 @@ public partial class Home
try
{
_ = await ApiClient.RequestAsync<MeResponse>("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; }

View File

@@ -76,8 +76,7 @@
</button>
</nav>
}
@if (IsManagementScreen)
else if (IsManagementScreen)
{
<CampaignManagementPanel
Campaigns="Campaigns"
@@ -96,6 +95,59 @@
EditCharacterRequested="OpenEditCharacterModal"
DeleteCharacterRequested="DeleteCharacterAsync"/>
}
else if (IsAdminScreen)
{
<main class="management-screen">
<section class="card">
<div class="section-head">
<h2>User Management</h2>
</div>
@if (IsAdminDataLoading)
{
<p class="empty">Loading users...</p>
}
else if (!IsCurrentUserAdmin)
{
<p class="empty">Admin role is required to manage users.</p>
}
else if (AdminUsers.Count == 0)
{
<p class="empty">No users found.</p>
}
else
{
<ul class="management-list">
@foreach (var user in AdminUsers)
{
<li>
<div>
<strong>@user.Username</strong>
<p class="muted">@user.DisplayName</p>
<p class="muted">Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))</p>
</div>
<div class="skill-chip-actions">
<button type="button"
class="chip-button"
disabled="@(IsMutating || user.Id == User?.Id)"
@onclick="() => ToggleAdminRoleAsync(user)">
<span aria-hidden="true" class="emoji">🛡️</span>
<span class="sr-only">Toggle admin role for @user.Username</span>
</button>
<button type="button"
class="chip-button"
disabled="@(IsMutating || user.Id == User?.Id)"
@onclick="() => DeleteUserAsync(user)">
<span aria-hidden="true" class="emoji">🗑️</span>
<span class="sr-only">Delete user @user.Username</span>
</button>
</div>
</li>
}
</ul>
}
</section>
</main>
}
</div>
@if (Toasts.Count > 0)

View File

@@ -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<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
if (string.Equals(storedScreen, "management", StringComparison.OrdinalIgnoreCase))
CurrentScreen = "management";
}
var storedScreen = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
CurrentScreen = NormalizeRequestedScreen(storedScreen) ?? ScreenPlay;
var storedPanel = await JS.InvokeAsync<string?>("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<IReadOnlyList<AdminUserSummary>>("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<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)
@@ -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<CampaignOption> CharacterCampaignOptions { get; set; } = [];
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
private List<RulesetDefinition> Rulesets { get; set; } = [];
private List<AdminUserSummary> AdminUsers { get; set; } = [];
private Guid? SelectedCharacterId { get; set; }
private RollResult? LastRoll { get; set; }
private List<string> 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<WorkspaceToast> Toasts { get; } = [];
@@ -839,12 +966,6 @@ public partial class Workspace : IAsyncDisposable
[Parameter]
public EventCallback<string?> 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<SkillSummary> SelectedCharacterSkills =>
SelectedCampaign is null || !SelectedCharacterId.HasValue ? [] : SelectedCampaign.Skills.Where(skill => skill.CharacterId == SelectedCharacterId.Value).OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
private List<SkillGroupSummary> 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<AppHeaderMenuItem> 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";