From 2e2f364c5e9261b7510924f561890380860dbf18 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 26 Feb 2026 17:15:10 +0100 Subject: [PATCH] Add admin roles, user management, and campaign deletion --- README.md | 2 + RpgRoller.Tests/Api/AuthApiTests.cs | 1 + RpgRoller.Tests/Api/CampaignApiTests.cs | 48 ++++ RpgRoller.Tests/HostingCoverageTests.cs | 39 ++- .../ServiceAdminAndCampaignDeletionTests.cs | 83 ++++++ RpgRoller/Api/AdminEndpoints.cs | 30 ++ RpgRoller/Api/ApiEndpointRegistration.cs | 3 +- RpgRoller/Api/CampaignEndpoints.cs | 8 +- RpgRoller/Components/Pages/Home.Models.cs | 3 +- RpgRoller/Components/Pages/Home.razor | 6 +- RpgRoller/Components/Pages/Home.razor.cs | 14 +- .../Pages/HomeControls/AdminHome.razor | 76 ++++++ .../Pages/HomeControls/AdminHome.razor.cs | 170 ++++++++++++ .../CampaignManagementPanel.razor | 7 + .../CampaignManagementPanel.razor.cs | 6 + .../HomeControls/CharacterFormModal.razor.cs | 8 +- RpgRoller/Components/Pages/Workspace.razor | 10 + RpgRoller/Components/Pages/Workspace.razor.cs | 48 +++- RpgRoller/Contracts/ApiContracts.cs | 8 +- RpgRoller/Data/RpgRollerDbContext.cs | 1 + RpgRoller/Domain/GameModels.cs | 8 +- ...zationRolesAndCampaignDeletion.Designer.cs | 258 ++++++++++++++++++ ...ddAuthorizationRolesAndCampaignDeletion.cs | 55 ++++ .../RpgRollerDbContextModelSnapshot.cs | 7 +- RpgRoller/Services/GameService.cs | 255 +++++++++++++++-- RpgRoller/Services/IGameService.cs | 4 + 26 files changed, 1127 insertions(+), 31 deletions(-) create mode 100644 RpgRoller.Tests/Services/ServiceAdminAndCampaignDeletionTests.cs create mode 100644 RpgRoller/Api/AdminEndpoints.cs create mode 100644 RpgRoller/Components/Pages/HomeControls/AdminHome.razor create mode 100644 RpgRoller/Components/Pages/HomeControls/AdminHome.razor.cs create mode 100644 RpgRoller/Migrations/20260226160859_AddAuthorizationRolesAndCampaignDeletion.Designer.cs create mode 100644 RpgRoller/Migrations/20260226160859_AddAuthorizationRolesAndCampaignDeletion.cs diff --git a/README.md b/README.md index 6b8c463..63f7cd9 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ Gameplay capabilities now include: - Skill and skill-group deletion flows - GM-driven character owner transfer within campaign management flows - Character owner selection in edit modal backed by existing-username dropdown data +- Role-aware authorization with admin role support (including admin user/role management) +- Campaign deletion by campaign owner or admin (unlinks characters from the campaign and clears campaign log entries) ## Prerequisites diff --git a/RpgRoller.Tests/Api/AuthApiTests.cs b/RpgRoller.Tests/Api/AuthApiTests.cs index fc1a173..1852b52 100644 --- a/RpgRoller.Tests/Api/AuthApiTests.cs +++ b/RpgRoller.Tests/Api/AuthApiTests.cs @@ -14,6 +14,7 @@ public sealed class AuthApiTests : ApiTestBase var registerResult = await RegisterAsync(client, "alice", "Password123", "Alice"); Assert.Equal("alice", registerResult.Username); + Assert.Contains(registerResult.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)); var duplicate = await client.PostAsJsonAsync("/api/auth/register", new RegisterRequest("alice", "Password123", "Alice 2")); Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode); diff --git a/RpgRoller.Tests/Api/CampaignApiTests.cs b/RpgRoller.Tests/Api/CampaignApiTests.cs index f541b22..d2fad7f 100644 --- a/RpgRoller.Tests/Api/CampaignApiTests.cs +++ b/RpgRoller.Tests/Api/CampaignApiTests.cs @@ -103,4 +103,52 @@ public sealed class CampaignApiTests : ApiTestBase var receiverActivate = await receiverClient.PostAsync($"/api/characters/{character.Id}/activate", null); Assert.Equal(HttpStatusCode.OK, receiverActivate.StatusCode); } + + [Fact] + public async Task AdminUserManagementAndCampaignDeletion_WorkThroughApi() + { + using var factory = CreateFactory(6, 5, 4); + using var adminClient = factory.CreateClient(new() { AllowAutoRedirect = false }); + using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false }); + using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false }); + + var admin = await RegisterAsync(adminClient, "admin3", "Password123", "Admin"); + var gm = await RegisterAsync(gmClient, "gm3", "Password123", "GM"); + var player = await RegisterAsync(playerClient, "player3", "Password123", "Player"); + + await LoginAsync(adminClient, "admin3", "Password123"); + await LoginAsync(gmClient, "gm3", "Password123"); + await LoginAsync(playerClient, "player3", "Password123"); + + var adminUsers = await GetAsync>(adminClient, "/api/admin/users"); + var adminEntry = adminUsers.Single(user => user.Id == admin.Id); + var playerEntry = adminUsers.Single(user => user.Id == player.Id); + Assert.Contains(adminEntry.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)); + Assert.Empty(playerEntry.Roles); + + var promotedPlayer = await PutAsync(adminClient, $"/api/admin/users/{player.Id}/roles", new([ "admin" ])); + Assert.Contains(promotedPlayer.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)); + + var campaign = await PostAsync(gmClient, "/api/campaigns", new("Disposable Campaign", "d6")); + var character = await PostAsync(playerClient, "/api/characters", new("Disposable Hero", campaign.Id)); + var skill = await PostAsync(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true)); + _ = await PostAsync(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); + + var deleteCampaign = await adminClient.DeleteAsync($"/api/campaigns/{campaign.Id}"); + Assert.Equal(HttpStatusCode.OK, deleteCampaign.StatusCode); + + var getDeletedCampaign = await gmClient.GetAsync($"/api/campaigns/{campaign.Id}"); + Assert.Equal(HttpStatusCode.BadRequest, getDeletedCampaign.StatusCode); + + var playerCharacters = await GetAsync>(playerClient, "/api/characters"); + Assert.Single(playerCharacters); + Assert.Null(playerCharacters[0].CampaignId); + + var deleteUser = await adminClient.DeleteAsync($"/api/admin/users/{player.Id}"); + Assert.Equal(HttpStatusCode.OK, deleteUser.StatusCode); + + var usersAfterDelete = await GetAsync>(adminClient, "/api/admin/users"); + Assert.DoesNotContain(usersAfterDelete, user => user.Id == player.Id); + Assert.Contains(usersAfterDelete, user => user.Id == gm.Id); + } } diff --git a/RpgRoller.Tests/HostingCoverageTests.cs b/RpgRoller.Tests/HostingCoverageTests.cs index 5321af4..67e062c 100644 --- a/RpgRoller.Tests/HostingCoverageTests.cs +++ b/RpgRoller.Tests/HostingCoverageTests.cs @@ -82,6 +82,9 @@ public sealed class HostingCoverageTests "Breakdown" TEXT NOT NULL, "TimestampUtc" TEXT NOT NULL ); + + INSERT INTO "Users" ("Id", "Username", "UsernameNormalized", "PasswordHash", "DisplayName", "ActiveCharacterId") + VALUES ('00000000-0000-0000-0000-000000000001', 'legacy-admin', 'LEGACY-ADMIN', 'hash', 'Legacy Admin', NULL); """; _ = command.ExecuteNonQuery(); } @@ -115,6 +118,35 @@ public sealed class HostingCoverageTests Assert.Contains("Dice", rollColumns); + using var usersTableInfoCommand = verifyConnection.CreateCommand(); + usersTableInfoCommand.CommandText = "PRAGMA table_info('Users');"; + using var usersTableInfoReader = usersTableInfoCommand.ExecuteReader(); + var usersColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + while (usersTableInfoReader.Read()) + usersColumns.Add(usersTableInfoReader.GetString(1)); + + Assert.Contains("Roles", usersColumns); + + using var usersRoleCommand = verifyConnection.CreateCommand(); + usersRoleCommand.CommandText = "SELECT Roles FROM Users WHERE UsernameNormalized = 'LEGACY-ADMIN';"; + var roles = Convert.ToString(usersRoleCommand.ExecuteScalar()); + Assert.Equal("admin", roles); + + using var charactersTableInfoCommand = verifyConnection.CreateCommand(); + charactersTableInfoCommand.CommandText = "PRAGMA table_info('Characters');"; + using var charactersTableInfoReader = charactersTableInfoCommand.ExecuteReader(); + var campaignIdNotNull = true; + while (charactersTableInfoReader.Read()) + { + if (!string.Equals(charactersTableInfoReader.GetString(1), "CampaignId", StringComparison.OrdinalIgnoreCase)) + continue; + + campaignIdNotNull = charactersTableInfoReader.GetInt32(3) == 1; + break; + } + + Assert.False(campaignIdNotNull); + using var historyCommand = verifyConnection.CreateCommand(); historyCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226084000_InitialSchema';"; var historyCount = Convert.ToInt32(historyCommand.ExecuteScalar()); @@ -129,5 +161,10 @@ public sealed class HostingCoverageTests rollDiceHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226100000_AddRollLogDice';"; var rollDiceHistoryCount = Convert.ToInt32(rollDiceHistoryCommand.ExecuteScalar()); Assert.Equal(1, rollDiceHistoryCount); + + using var rolesHistoryCommand = verifyConnection.CreateCommand(); + rolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226160859_AddAuthorizationRolesAndCampaignDeletion';"; + var rolesHistoryCount = Convert.ToInt32(rolesHistoryCommand.ExecuteScalar()); + Assert.Equal(1, rolesHistoryCount); } -} \ No newline at end of file +} diff --git a/RpgRoller.Tests/Services/ServiceAdminAndCampaignDeletionTests.cs b/RpgRoller.Tests/Services/ServiceAdminAndCampaignDeletionTests.cs new file mode 100644 index 0000000..6819531 --- /dev/null +++ b/RpgRoller.Tests/Services/ServiceAdminAndCampaignDeletionTests.cs @@ -0,0 +1,83 @@ +using RpgRoller.Domain; + +namespace RpgRoller.Tests; + +public sealed class ServiceAdminAndCampaignDeletionTests +{ + [Fact] + public void AdminRoleManagement_RequiresAdminAndProtectsSelf() + { + using var harness = ServiceTestSupport.CreateHarness(); + var service = harness.Service; + + var bootstrapAdmin = ServiceTestSupport.GetValue(service.Register("admin", "Password123", "Admin")); + _ = ServiceTestSupport.GetValue(service.Register("member", "Password123", "Member")); + + Assert.Contains(bootstrapAdmin.Roles, role => string.Equals(role, UserRoles.Admin, StringComparison.OrdinalIgnoreCase)); + + var adminSession = ServiceTestSupport.GetValue(service.Login("admin", "Password123")).SessionToken; + var memberSession = ServiceTestSupport.GetValue(service.Login("member", "Password123")).SessionToken; + + var forbiddenList = service.GetUsers(memberSession); + Assert.False(forbiddenList.Succeeded); + + var users = ServiceTestSupport.GetValue(service.GetUsers(adminSession)); + var memberUser = users.Single(user => string.Equals(user.Username, "member", StringComparison.OrdinalIgnoreCase)); + Assert.Empty(memberUser.Roles); + + var promoted = ServiceTestSupport.GetValue(service.UpdateUserRoles(adminSession, memberUser.Id, [UserRoles.Admin])); + Assert.Contains(promoted.Roles, role => string.Equals(role, UserRoles.Admin, StringComparison.OrdinalIgnoreCase)); + + var selfDemote = service.UpdateUserRoles(adminSession, bootstrapAdmin.Id, Array.Empty()); + Assert.False(selfDemote.Succeeded); + + var selfDelete = service.DeleteUser(adminSession, bootstrapAdmin.Id); + Assert.False(selfDelete.Succeeded); + + var deletedMember = ServiceTestSupport.GetValue(service.DeleteUser(adminSession, memberUser.Id)); + Assert.True(deletedMember); + + var remainingUsers = ServiceTestSupport.GetValue(service.GetUsers(adminSession)); + Assert.Single(remainingUsers); + Assert.Equal(bootstrapAdmin.Id, remainingUsers[0].Id); + } + + [Fact] + public void CampaignDeletion_ByOwnerOrAdmin_UnlinksCharactersAndClearsLog() + { + using var harness = ServiceTestSupport.CreateHarness(4, 5, 6); + var service = harness.Service; + + _ = ServiceTestSupport.GetValue(service.Register("admin", "Password123", "Admin")); + _ = ServiceTestSupport.GetValue(service.Register("gm", "Password123", "GM")); + _ = ServiceTestSupport.GetValue(service.Register("player", "Password123", "Player")); + + var adminSession = ServiceTestSupport.GetValue(service.Login("admin", "Password123")).SessionToken; + var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken; + var playerSession = ServiceTestSupport.GetValue(service.Login("player", "Password123")).SessionToken; + + var ownerDeletedCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Owner Delete", "d6")); + var ownerDeleteResult = ServiceTestSupport.GetValue(service.DeleteCampaign(gmSession, ownerDeletedCampaign.Id)); + Assert.True(ownerDeleteResult); + Assert.False(service.GetCampaign(gmSession, ownerDeletedCampaign.Id).Succeeded); + + var adminDeletedCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Admin Delete", "d6")); + var playerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(playerSession, "Scout", adminDeletedCampaign.Id)); + var playerSkill = ServiceTestSupport.GetValue(service.CreateSkill(playerSession, playerCharacter.Id, "Stealth", "2D+1", 1, true)); + _ = ServiceTestSupport.GetValue(service.RollSkill(playerSession, playerSkill.Id, "public")); + + var forbiddenDelete = service.DeleteCampaign(playerSession, adminDeletedCampaign.Id); + Assert.False(forbiddenDelete.Succeeded); + + var adminDelete = ServiceTestSupport.GetValue(service.DeleteCampaign(adminSession, adminDeletedCampaign.Id)); + Assert.True(adminDelete); + Assert.False(service.GetCampaign(gmSession, adminDeletedCampaign.Id).Succeeded); + + var playerCharacters = ServiceTestSupport.GetValue(service.GetOwnCharacters(playerSession)); + Assert.Single(playerCharacters); + Assert.Null(playerCharacters[0].CampaignId); + + using var db = harness.CreateDbContext(); + Assert.Empty(db.RollLogEntries.Where(entry => entry.CampaignId == adminDeletedCampaign.Id)); + } +} diff --git a/RpgRoller/Api/AdminEndpoints.cs b/RpgRoller/Api/AdminEndpoints.cs new file mode 100644 index 0000000..2cd5cd2 --- /dev/null +++ b/RpgRoller/Api/AdminEndpoints.cs @@ -0,0 +1,30 @@ +using RpgRoller.Contracts; +using RpgRoller.Services; + +namespace RpgRoller.Api; + +internal static class AdminEndpoints +{ + public static RouteGroupBuilder MapAdminEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/admin/users", (HttpContext context, IGameService game) => + { + var result = game.GetUsers(context.GetRequiredSessionToken()); + return ApiResultMapper.ToApiResult(result); + }); + + group.MapPut("/admin/users/{userId:guid}/roles", (Guid userId, UpdateUserRolesRequest request, HttpContext context, IGameService game) => + { + var result = game.UpdateUserRoles(context.GetRequiredSessionToken(), userId, request.Roles); + return ApiResultMapper.ToApiResult(result); + }); + + group.MapDelete("/admin/users/{userId:guid}", (Guid userId, HttpContext context, IGameService game) => + { + var result = game.DeleteUser(context.GetRequiredSessionToken(), userId); + return ApiResultMapper.ToApiResult(result); + }); + + return group; + } +} diff --git a/RpgRoller/Api/ApiEndpointRegistration.cs b/RpgRoller/Api/ApiEndpointRegistration.cs index 262b337..944e12b 100644 --- a/RpgRoller/Api/ApiEndpointRegistration.cs +++ b/RpgRoller/Api/ApiEndpointRegistration.cs @@ -13,7 +13,8 @@ public static class ApiEndpointRegistration authenticatedApi.MapMeEndpoints(); authenticatedApi.MapCampaignEndpoints(); authenticatedApi.MapCharacterEndpoints(); + authenticatedApi.MapAdminEndpoints(); authenticatedApi.MapSkillEndpoints(); authenticatedApi.MapStateEventEndpoints(); } -} \ No newline at end of file +} diff --git a/RpgRoller/Api/CampaignEndpoints.cs b/RpgRoller/Api/CampaignEndpoints.cs index 883a81b..cb19e54 100644 --- a/RpgRoller/Api/CampaignEndpoints.cs +++ b/RpgRoller/Api/CampaignEndpoints.cs @@ -31,6 +31,12 @@ internal static class CampaignEndpoints return ApiResultMapper.ToApiResult(result); }); + group.MapDelete("/campaigns/{campaignId:guid}", (Guid campaignId, HttpContext context, IGameService game) => + { + var result = game.DeleteCampaign(context.GetRequiredSessionToken(), campaignId); + return ApiResultMapper.ToApiResult(result); + }); + return group; } -} \ No newline at end of file +} diff --git a/RpgRoller/Components/Pages/Home.Models.cs b/RpgRoller/Components/Pages/Home.Models.cs index cfd5d9f..e2a66a8 100644 --- a/RpgRoller/Components/Pages/Home.Models.cs +++ b/RpgRoller/Components/Pages/Home.Models.cs @@ -60,5 +60,6 @@ public enum HomeViewMode { Loading, Anonymous, - Workspace + Workspace, + Admin } diff --git a/RpgRoller/Components/Pages/Home.razor b/RpgRoller/Components/Pages/Home.razor index 9c47300..8366693 100644 --- a/RpgRoller/Components/Pages/Home.razor +++ b/RpgRoller/Components/Pages/Home.razor @@ -22,6 +22,10 @@ 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 9f48d54..e7cf0f5 100644 --- a/RpgRoller/Components/Pages/Home.razor.cs +++ b/RpgRoller/Components/Pages/Home.razor.cs @@ -43,6 +43,18 @@ public partial class Home ClearStatus(); } + private void OnAdminRequested() + { + CurrentView = HomeViewMode.Admin; + ClearStatus(); + } + + private void OnWorkspaceRequested() + { + CurrentView = HomeViewMode.Workspace; + ClearStatus(); + } + private void OnLoggedOutAsync(string? message) { CurrentView = HomeViewMode.Anonymous; @@ -75,4 +87,4 @@ public partial class Home [Inject] private RpgRollerApiClient ApiClient { get; set; } = null!; -} \ No newline at end of file +} diff --git a/RpgRoller/Components/Pages/HomeControls/AdminHome.razor b/RpgRoller/Components/Pages/HomeControls/AdminHome.razor new file mode 100644 index 0000000..ced8447 --- /dev/null +++ b/RpgRoller/Components/Pages/HomeControls/AdminHome.razor @@ -0,0 +1,76 @@ +
+
+
+
+

Admin

+
+ @if (CurrentUser is null) + { +

Loading admin session...

+ } + else + { +

@CurrentUser.DisplayName (@CurrentUser.Username)

+ } + +
+ + Logout +
+ @if (!string.IsNullOrWhiteSpace(StatusMessage)) + { +

@StatusMessage

+ } +
+ +
+
+

User Management

+
+ @if (IsLoading) + { +

Loading users...

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

Admin role is required to manage users.

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

No users found.

+ } + else + { +
    + @foreach (var user in Users) + { + var userIsAdmin = HasAdminRole(user); +
  • +
    + @user.Username +

    @user.DisplayName

    +

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

    +
    +
    + + +
    +
  • + } +
+ } +
+
+
diff --git a/RpgRoller/Components/Pages/HomeControls/AdminHome.razor.cs b/RpgRoller/Components/Pages/HomeControls/AdminHome.razor.cs new file mode 100644 index 0000000..9da44c6 --- /dev/null +++ b/RpgRoller/Components/Pages/HomeControls/AdminHome.razor.cs @@ -0,0 +1,170 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using RpgRoller.Contracts; +using RpgRoller.Domain; + +namespace RpgRoller.Components.Pages.HomeControls; + +[ExcludeFromCodeCoverage] +public partial class AdminHome +{ + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + await InitializeAsync(); + await InvokeAsync(StateHasChanged); + } + + private async Task InitializeAsync() + { + try + { + var me = await ApiClient.RequestAsync("GET", "/api/me"); + CurrentUser = me.User; + IsCurrentUserAdmin = HasAdminRole(me.User); + if (!IsCurrentUserAdmin) + return; + + Users = (await ApiClient.RequestAsync>("GET", "/api/admin/users")) + .OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch (ApiRequestException ex) when (ex.StatusCode == 401) + { + await LoggedOut.InvokeAsync("Session expired. Please log in again."); + } + catch (ApiRequestException ex) + { + SetStatus(ex.Message, true); + } + finally + { + IsLoading = false; + } + } + + private async Task BackToWorkspaceAsync() + { + await WorkspaceRequested.InvokeAsync(); + } + + private async Task LogoutAsync() + { + if (IsMutating) + return; + + IsMutating = true; + try + { + await ApiClient.RequestWithoutPayloadAsync("POST", "/api/auth/logout"); + } + catch (ApiRequestException) + { + } + finally + { + IsMutating = false; + } + + await LoggedOut.InvokeAsync("Logged out."); + } + + private async Task ToggleAdminRoleAsync(AdminUserSummary user) + { + if (IsMutating || CurrentUser is null || user.Id == CurrentUser.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 ReloadUsersAsync(); + SetStatus("User roles updated.", false); + } + catch (ApiRequestException ex) + { + SetStatus(ex.Message, true); + } + finally + { + IsMutating = false; + } + } + + private async Task DeleteUserAsync(AdminUserSummary user) + { + if (IsMutating || CurrentUser is null || user.Id == CurrentUser.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 ReloadUsersAsync(); + SetStatus("User deleted.", false); + } + catch (ApiRequestException ex) + { + SetStatus(ex.Message, true); + } + finally + { + IsMutating = false; + } + } + + private async Task ReloadUsersAsync() + { + Users = (await ApiClient.RequestAsync>("GET", "/api/admin/users")) + .OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static bool HasAdminRole(UserSummary user) + { + return user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase); + } + + private static bool HasAdminRole(AdminUserSummary user) + { + return user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase); + } + + private void SetStatus(string message, bool isError) + { + StatusMessage = message; + StatusIsError = isError; + } + + [Inject] + private RpgRollerApiClient ApiClient { get; set; } = null!; + + [Inject] + private IJSRuntime JS { get; set; } = null!; + + private bool IsLoading { get; set; } = true; + private bool IsMutating { get; set; } + private bool IsCurrentUserAdmin { get; set; } + private UserSummary? CurrentUser { get; set; } + private List Users { get; set; } = []; + private string? StatusMessage { get; set; } + private bool StatusIsError { get; set; } + + [Parameter] + public EventCallback LoggedOut { get; set; } + + [Parameter] + public EventCallback WorkspaceRequested { get; set; } +} diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor index e869fa0..e648c29 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor @@ -25,6 +25,13 @@ Add campaign + +
diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs index ddc8686..3081b10 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs @@ -92,12 +92,18 @@ public partial class CampaignManagementPanel [Parameter] public Func CanEditCharacter { get; set; } = _ => false; + [Parameter] + public bool CanDeleteCampaign { get; set; } + [Parameter] public EventCallback CampaignSelectionChanged { get; set; } [Parameter] public EventCallback CampaignCreated { get; set; } + [Parameter] + public EventCallback DeleteCampaignRequested { get; set; } + [Parameter] public EventCallback CreateCharacterRequested { get; set; } diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor.cs b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor.cs index 69d7221..5b70a25 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor.cs @@ -49,7 +49,13 @@ public partial class CharacterFormModal character = await ApiClient.RequestAsync("POST", "/api/characters", new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId)); } - await CharacterSaved.InvokeAsync(character.CampaignId); + if (!character.CampaignId.HasValue) + { + FormState.ErrorMessage = "Character must belong to a campaign."; + return; + } + + await CharacterSaved.InvokeAsync(character.CampaignId.Value); } catch (ApiRequestException ex) { diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor index b74a853..50abf67 100644 --- a/RpgRoller/Components/Pages/Workspace.razor +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -54,6 +54,14 @@ role="menuitem" @onclick="SwitchToManagementAsync">Campaign Management + @if (IsCurrentUserAdmin) + { + + } } @@ -119,8 +127,10 @@ IsMutating="IsMutating" OwnerLabel="OwnerLabel" CanEditCharacter="CanEditCharacter" + CanDeleteCampaign="CanDeleteSelectedCampaign" CampaignSelectionChanged="OnCampaignSelectionChangedAsync" CampaignCreated="OnCampaignCreatedAsync" + DeleteCampaignRequested="DeleteSelectedCampaignAsync" CreateCharacterRequested="OpenCreateCharacterModal" EditCharacterRequested="OpenEditCharacterModal"/> } diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index 2548585..4edb579 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; using RpgRoller.Contracts; +using RpgRoller.Domain; namespace RpgRoller.Components.Pages; @@ -231,6 +232,12 @@ public partial class Workspace : IAsyncDisposable return SwitchScreenAsync("management"); } + private async Task OpenAdminAsync() + { + IsScreenMenuOpen = false; + await AdminRequested.InvokeAsync(); + } + private async Task SetMobilePanelAsync(string panel) { MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character"; @@ -290,7 +297,7 @@ public partial class Workspace : IAsyncDisposable EditCharacterInitialModel = new() { Name = character.Name, - CampaignId = character.CampaignId.ToString(), + CampaignId = character.CampaignId?.ToString() ?? string.Empty, OwnerUsername = string.Empty }; @@ -325,6 +332,34 @@ public partial class Workspace : IAsyncDisposable SetStatus("Character updated.", false); } + private async Task DeleteSelectedCampaignAsync() + { + if (SelectedCampaign is null || IsMutating || !CanDeleteSelectedCampaign) + return; + + var confirmed = await JS.InvokeAsync("confirm", $"Delete campaign '{SelectedCampaign.Name}'?"); + if (!confirmed) + return; + + IsMutating = true; + try + { + _ = await ApiClient.RequestAsync("DELETE", $"/api/campaigns/{SelectedCampaign.Id}"); + await ReloadCampaignsAsync(null); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + SetStatus("Campaign deleted.", false); + } + catch (ApiRequestException ex) + { + SetStatus(ex.Message, true); + } + finally + { + IsMutating = false; + } + } + private async Task SelectCharacterAsync(Guid characterId) { SelectedCharacterId = characterId; @@ -577,7 +612,7 @@ public partial class Workspace : IAsyncDisposable return skill.DiceRollDefinition; var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off"; - return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumbleLabel}"; + return $"{skill.DiceRollDefinition}, wild {skill.WildDice}, {fumbleLabel}"; } private string RollerLabel(CampaignLogEntry entry) @@ -728,6 +763,9 @@ public partial class Workspace : IAsyncDisposable [Parameter] public EventCallback LoggedOut { get; set; } + [Parameter] + public EventCallback AdminRequested { get; set; } + private string? SelectedCampaignName => SelectedCampaign?.Name; private CharacterSummary? SelectedCharacter => @@ -736,6 +774,12 @@ public partial class Workspace : IAsyncDisposable private bool IsCurrentUserGm => SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id; + private bool IsCurrentUserAdmin => + User is not null && User.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase); + + private bool CanDeleteSelectedCampaign => + SelectedCampaign is not null && User is not null && (SelectedCampaign.Gm.Id == User.Id || IsCurrentUserAdmin); + private bool IsSelectedCampaignD6 => string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase); diff --git a/RpgRoller/Contracts/ApiContracts.cs b/RpgRoller/Contracts/ApiContracts.cs index 873b5e2..5e27fc9 100644 --- a/RpgRoller/Contracts/ApiContracts.cs +++ b/RpgRoller/Contracts/ApiContracts.cs @@ -8,10 +8,14 @@ public sealed record RegisterRequest(string Username, string Password, string Di public sealed record LoginRequest(string Username, string Password); -public sealed record UserSummary(Guid Id, string Username, string DisplayName); +public sealed record UserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList Roles); public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId); +public sealed record AdminUserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList Roles); + +public sealed record UpdateUserRolesRequest(IReadOnlyList Roles); + public sealed record RulesetDefinition(string Id, string Name, string DiceSyntax); public sealed record CreateCampaignRequest(string Name, string RulesetId); @@ -22,7 +26,7 @@ public sealed record CreateCharacterRequest(string Name, Guid CampaignId); public sealed record UpdateCharacterRequest(string Name, Guid CampaignId, string? OwnerUsername = null); -public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid CampaignId); +public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid? CampaignId); public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null); diff --git a/RpgRoller/Data/RpgRollerDbContext.cs b/RpgRoller/Data/RpgRollerDbContext.cs index 687e4c2..077e661 100644 --- a/RpgRoller/Data/RpgRollerDbContext.cs +++ b/RpgRoller/Data/RpgRollerDbContext.cs @@ -18,6 +18,7 @@ public sealed class RpgRollerDbContext : DbContext entity.Property(x => x.UsernameNormalized).IsRequired().HasMaxLength(64); entity.Property(x => x.PasswordHash).IsRequired(); entity.Property(x => x.DisplayName).IsRequired().HasMaxLength(128); + entity.Property(x => x.Roles).IsRequired().HasMaxLength(256); entity.HasIndex(x => x.UsernameNormalized).IsUnique(); }); diff --git a/RpgRoller/Domain/GameModels.cs b/RpgRoller/Domain/GameModels.cs index 6e6c5ac..ff79bfa 100644 --- a/RpgRoller/Domain/GameModels.cs +++ b/RpgRoller/Domain/GameModels.cs @@ -19,9 +19,15 @@ public sealed class UserAccount public required string UsernameNormalized { get; init; } public required string PasswordHash { get; set; } public required string DisplayName { get; set; } + public required string Roles { get; set; } public Guid? ActiveCharacterId { get; set; } } +public static class UserRoles +{ + public const string Admin = "admin"; +} + public sealed class UserSession { public required string Token { get; init; } @@ -42,7 +48,7 @@ public sealed class Character { public required Guid Id { get; init; } public required Guid OwnerUserId { get; set; } - public required Guid CampaignId { get; set; } + public Guid? CampaignId { get; set; } public required string Name { get; set; } } diff --git a/RpgRoller/Migrations/20260226160859_AddAuthorizationRolesAndCampaignDeletion.Designer.cs b/RpgRoller/Migrations/20260226160859_AddAuthorizationRolesAndCampaignDeletion.Designer.cs new file mode 100644 index 0000000..7f774b8 --- /dev/null +++ b/RpgRoller/Migrations/20260226160859_AddAuthorizationRolesAndCampaignDeletion.Designer.cs @@ -0,0 +1,258 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RpgRoller.Data; + +#nullable disable + +namespace RpgRoller.Migrations +{ + [DbContext(typeof(RpgRollerDbContext))] + [Migration("20260226160859_AddAuthorizationRolesAndCampaignDeletion")] + partial class AddAuthorizationRolesAndCampaignDeletion + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("RpgRoller.Domain.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GmUserId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Ruleset") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GmUserId"); + + b.ToTable("Campaigns"); + }); + + modelBuilder.Entity("RpgRoller.Domain.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CampaignId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Breakdown") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CampaignId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Dice") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Result") + .HasColumnType("INTEGER"); + + b.Property("RollerUserId") + .HasColumnType("TEXT"); + + b.Property("SkillId") + .HasColumnType("TEXT"); + + b.Property("TimestampUtc") + .HasColumnType("TEXT"); + + b.Property("Visibility") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("RollerUserId"); + + b.HasIndex("SkillId"); + + b.ToTable("RollLogEntries"); + }); + + modelBuilder.Entity("RpgRoller.Domain.Skill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowFumble") + .HasColumnType("INTEGER"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("DiceRollDefinition") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SkillGroupId") + .HasColumnType("TEXT"); + + b.Property("WildDice") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("SkillGroupId"); + + b.ToTable("Skills"); + }); + + modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowFumble") + .HasColumnType("INTEGER"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("DiceRollDefinition") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("WildDice") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.ToTable("SkillGroups"); + }); + + modelBuilder.Entity("RpgRoller.Domain.UserAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveCharacterId") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Roles") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UsernameNormalized") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UsernameNormalized") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("RpgRoller.Domain.UserSession", b => + { + b.Property("Token") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("Sessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/RpgRoller/Migrations/20260226160859_AddAuthorizationRolesAndCampaignDeletion.cs b/RpgRoller/Migrations/20260226160859_AddAuthorizationRolesAndCampaignDeletion.cs new file mode 100644 index 0000000..883ed43 --- /dev/null +++ b/RpgRoller/Migrations/20260226160859_AddAuthorizationRolesAndCampaignDeletion.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RpgRoller.Migrations +{ + /// + public partial class AddAuthorizationRolesAndCampaignDeletion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Roles", + table: "Users", + type: "TEXT", + maxLength: 256, + nullable: false, + defaultValue: "admin"); + + migrationBuilder.AlterColumn( + name: "CampaignId", + table: "Characters", + type: "TEXT", + nullable: true, + oldClrType: typeof(Guid), + oldType: "TEXT"); + + migrationBuilder.Sql(""" + UPDATE Users + SET Roles = 'admin' + WHERE Roles IS NULL OR TRIM(Roles) = ''; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Roles", + table: "Users"); + + migrationBuilder.AlterColumn( + name: "CampaignId", + table: "Characters", + type: "TEXT", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "TEXT", + oldNullable: true); + } + } +} diff --git a/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs b/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs index e5e756e..d36cb19 100644 --- a/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs +++ b/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs @@ -51,7 +51,7 @@ namespace RpgRoller.Migrations .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("CampaignId") + b.Property("CampaignId") .HasColumnType("TEXT"); b.Property("Name") @@ -208,6 +208,11 @@ namespace RpgRoller.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("Roles") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + b.Property("Username") .IsRequired() .HasMaxLength(64) diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index 7079338..63009c6 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -47,6 +47,7 @@ public sealed class GameService : IGameService UsernameNormalized = normalizedUsername, DisplayName = displayName.Trim(), PasswordHash = string.Empty, + Roles = m_UsersById.Count == 0 ? UserRoles.Admin : string.Empty, ActiveCharacterId = null }; @@ -165,11 +166,21 @@ public sealed class GameService : IGameService if (user is null) return ServiceResult>.Failure("unauthorized", "You must be logged in."); - var campaignIds = new HashSet(m_CampaignsById.Values.Where(c => c.GmUserId == user.Id).Select(c => c.Id)); - foreach (var character in m_CharactersById.Values.Where(c => c.OwnerUserId == user.Id)) - campaignIds.Add(character.CampaignId); + IEnumerable visibleCampaigns; + if (UserHasRoleLocked(user, UserRoles.Admin)) + { + visibleCampaigns = m_CampaignsById.Values; + } + else + { + var campaignIds = new HashSet(m_CampaignsById.Values.Where(campaign => campaign.GmUserId == user.Id).Select(campaign => campaign.Id)); + foreach (var character in m_CharactersById.Values.Where(character => character.OwnerUserId == user.Id && character.CampaignId.HasValue)) + campaignIds.Add(character.CampaignId!.Value); - var results = campaignIds.Select(id => m_CampaignsById[id]).OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).Select(ToCampaignDetails).ToArray(); + visibleCampaigns = campaignIds.Select(campaignId => m_CampaignsById[campaignId]); + } + + var results = visibleCampaigns.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).Select(ToCampaignDetails).ToArray(); return ServiceResult>.Success(results); } @@ -196,6 +207,26 @@ public sealed class GameService : IGameService } } + public ServiceResult DeleteCampaign(string sessionToken, Guid campaignId) + { + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!m_CampaignsById.TryGetValue(campaignId, out var campaign)) + return ServiceResult.Failure("campaign_not_found", "Campaign was not found."); + + if (campaign.GmUserId != user.Id && !UserHasRoleLocked(user, UserRoles.Admin)) + return ServiceResult.Failure("forbidden", "Only the campaign owner or admin can delete this campaign."); + + DeleteCampaignLocked(campaignId); + PersistStateLocked(); + return ServiceResult.Success(true); + } + } + public ServiceResult> GetUsernames(string sessionToken) { lock (m_Gate) @@ -209,6 +240,91 @@ public sealed class GameService : IGameService } } + public ServiceResult> GetUsers(string sessionToken) + { + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult>.Failure("unauthorized", "You must be logged in."); + + if (!UserHasRoleLocked(user, UserRoles.Admin)) + return ServiceResult>.Failure("forbidden", "Admin role is required."); + + var users = m_UsersById.Values + .OrderBy(account => account.Username, StringComparer.OrdinalIgnoreCase) + .Select(ToAdminUserSummary) + .ToArray(); + return ServiceResult>.Success(users); + } + } + + public ServiceResult UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList roles) + { + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!UserHasRoleLocked(user, UserRoles.Admin)) + return ServiceResult.Failure("forbidden", "Admin role is required."); + + if (!m_UsersById.TryGetValue(userId, out var targetUser)) + return ServiceResult.Failure("user_not_found", "User was not found."); + + var normalizedRoles = NormalizeRoles(roles); + if (normalizedRoles.Any(role => !string.Equals(role, UserRoles.Admin, StringComparison.Ordinal))) + return ServiceResult.Failure("invalid_role", "Unsupported role."); + + if (user.Id == targetUser.Id && !normalizedRoles.Contains(UserRoles.Admin, StringComparer.Ordinal)) + return ServiceResult.Failure("forbidden", "You cannot remove your own admin role."); + + targetUser.Roles = SerializeRoles(normalizedRoles); + PersistStateLocked(); + return ServiceResult.Success(ToAdminUserSummary(targetUser)); + } + } + + public ServiceResult DeleteUser(string sessionToken, Guid userId) + { + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!UserHasRoleLocked(user, UserRoles.Admin)) + return ServiceResult.Failure("forbidden", "Admin role is required."); + + if (user.Id == userId) + return ServiceResult.Failure("forbidden", "You cannot delete your own account."); + + if (!m_UsersById.TryGetValue(userId, out var targetUser)) + return ServiceResult.Failure("user_not_found", "User was not found."); + + var gmCampaignIds = m_CampaignsById.Values.Where(campaign => campaign.GmUserId == targetUser.Id).Select(campaign => campaign.Id).ToArray(); + foreach (var campaignId in gmCampaignIds) + DeleteCampaignLocked(campaignId); + + var ownedCharacterIds = m_CharactersById.Values.Where(character => character.OwnerUserId == targetUser.Id).Select(character => character.Id).ToArray(); + foreach (var characterId in ownedCharacterIds) + DeleteCharacterLocked(characterId); + + m_RollLog.RemoveAll(entry => entry.RollerUserId == targetUser.Id); + + var staleSessions = m_SessionsByToken.Values.Where(session => session.UserId == targetUser.Id).Select(session => session.Token).ToArray(); + foreach (var token in staleSessions) + m_SessionsByToken.Remove(token); + + m_UsersById.Remove(targetUser.Id); + m_UserIdsByUsername.Remove(targetUser.UsernameNormalized); + + PersistStateLocked(); + return ServiceResult.Success(true); + } + } + public ServiceResult CreateCharacter(string sessionToken, string name, Guid campaignId) { if (string.IsNullOrWhiteSpace(name)) @@ -256,9 +372,10 @@ public sealed class GameService : IGameService if (!m_CampaignsById.TryGetValue(campaignId, out var targetCampaign)) return ServiceResult.Failure("campaign_not_found", "Campaign was not found."); - var sourceCampaign = m_CampaignsById[character.CampaignId]; var isOwner = character.OwnerUserId == user.Id; - var isSourceGm = sourceCampaign.GmUserId == user.Id; + var isSourceGm = character.CampaignId.HasValue && + m_CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) && + sourceCampaign.GmUserId == user.Id; var isTargetGm = targetCampaign.GmUserId == user.Id; if (!isOwner && !isSourceGm && !isTargetGm) return ServiceResult.Failure("forbidden", "Only the owner or GM can edit this character."); @@ -344,7 +461,9 @@ public sealed class GameService : IGameService if (!m_CharactersById.TryGetValue(characterId, out var character)) return ServiceResult.Failure("character_not_found", "Character was not found."); - var campaign = m_CampaignsById[character.CampaignId]; + if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + if (!CanEditCharacterLocked(user.Id, character, campaign)) return ServiceResult.Failure("forbidden", "Only the owner or GM can manage skill groups."); @@ -385,7 +504,9 @@ public sealed class GameService : IGameService return ServiceResult.Failure("skill_group_not_found", "Skill group was not found."); var character = m_CharactersById[group.CharacterId]; - var campaign = m_CampaignsById[character.CampaignId]; + if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + if (!CanEditCharacterLocked(user.Id, character, campaign)) return ServiceResult.Failure("forbidden", "Only the owner or GM can manage skill groups."); @@ -416,7 +537,9 @@ public sealed class GameService : IGameService return ServiceResult.Failure("skill_group_not_found", "Skill group was not found."); var character = m_CharactersById[group.CharacterId]; - var campaign = m_CampaignsById[character.CampaignId]; + if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + if (!CanEditCharacterLocked(user.Id, character, campaign)) return ServiceResult.Failure("forbidden", "Only the owner or GM can manage skill groups."); @@ -445,7 +568,9 @@ public sealed class GameService : IGameService if (!m_CharactersById.TryGetValue(characterId, out var character)) return ServiceResult.Failure("character_not_found", "Character was not found."); - var campaign = m_CampaignsById[character.CampaignId]; + if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + if (!CanEditCharacterLocked(user.Id, character, campaign)) return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); @@ -491,7 +616,9 @@ public sealed class GameService : IGameService return ServiceResult.Failure("skill_not_found", "Skill was not found."); var character = m_CharactersById[skill.CharacterId]; - var campaign = m_CampaignsById[character.CampaignId]; + if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + if (!CanEditCharacterLocked(user.Id, character, campaign)) return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); @@ -527,7 +654,9 @@ public sealed class GameService : IGameService return ServiceResult.Failure("skill_not_found", "Skill was not found."); var character = m_CharactersById[skill.CharacterId]; - var campaign = m_CampaignsById[character.CampaignId]; + if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + if (!CanEditCharacterLocked(user.Id, character, campaign)) return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); @@ -551,7 +680,9 @@ public sealed class GameService : IGameService return ServiceResult.Failure("skill_not_found", "Skill was not found."); var character = m_CharactersById[skill.CharacterId]; - var campaign = m_CampaignsById[character.CampaignId]; + if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + if (!CanEditCharacterLocked(user.Id, character, campaign)) return ServiceResult.Failure("forbidden", "Only the owner or GM can roll this skill."); @@ -795,7 +926,12 @@ public sealed class GameService : IGameService private static UserSummary ToUserSummary(UserAccount user) { - return new(user.Id, user.Username, user.DisplayName); + return new(user.Id, user.Username, user.DisplayName, ParseRoles(user.Roles)); + } + + private static AdminUserSummary ToAdminUserSummary(UserAccount user) + { + return new(user.Id, user.Username, user.DisplayName, ParseRoles(user.Roles)); } private CampaignDetails ToCampaignDetails(Campaign campaign) @@ -866,11 +1002,14 @@ public sealed class GameService : IGameService private bool CanViewCampaignLocked(Guid userId, Guid campaignId) { + if (m_UsersById.TryGetValue(userId, out var user) && UserHasRoleLocked(user, UserRoles.Admin)) + return true; + var campaign = m_CampaignsById[campaignId]; if (campaign.GmUserId == userId) return true; - return m_CharactersById.Values.Any(c => c.CampaignId == campaignId && c.OwnerUserId == userId); + return m_CharactersById.Values.Any(c => c.CampaignId.HasValue && c.CampaignId.Value == campaignId && c.OwnerUserId == userId); } private bool TryGetCurrentCampaignIdLocked(UserAccount user, out Guid campaignId) @@ -886,10 +1025,88 @@ public sealed class GameService : IGameService return false; } - campaignId = character.CampaignId; + if (!character.CampaignId.HasValue) + return false; + + campaignId = character.CampaignId.Value; return true; } + private bool TryResolveCharacterCampaignLocked(Character character, out Campaign campaign, out ServiceError? error) + { + campaign = default!; + if (!character.CampaignId.HasValue || !m_CampaignsById.TryGetValue(character.CampaignId.Value, out var resolvedCampaign) || resolvedCampaign is null) + { + error = new ServiceError("character_not_in_campaign", "Character is not linked to a campaign."); + return false; + } + + campaign = resolvedCampaign; + error = null; + return true; + } + + private void DeleteCampaignLocked(Guid campaignId) + { + if (!m_CampaignsById.Remove(campaignId)) + return; + + var affectedCharacterIds = m_CharactersById.Values.Where(character => character.CampaignId == campaignId).Select(character => character.Id).ToArray(); + foreach (var characterId in affectedCharacterIds) + m_CharactersById[characterId].CampaignId = null; + + m_RollLog.RemoveAll(entry => entry.CampaignId == campaignId); + } + + private void DeleteCharacterLocked(Guid characterId) + { + if (!m_CharactersById.TryGetValue(characterId, out var character)) + return; + + var campaignId = character.CampaignId; + m_CharactersById.Remove(characterId); + + var skillGroupIds = m_SkillGroupsById.Values.Where(group => group.CharacterId == characterId).Select(group => group.Id).ToHashSet(); + foreach (var skillGroupId in skillGroupIds) + m_SkillGroupsById.Remove(skillGroupId); + + var skillIds = m_SkillsById.Values.Where(skill => skill.CharacterId == characterId).Select(skill => skill.Id).ToHashSet(); + foreach (var skillId in skillIds) + m_SkillsById.Remove(skillId); + + m_RollLog.RemoveAll(entry => entry.CharacterId == characterId || skillIds.Contains(entry.SkillId)); + + foreach (var user in m_UsersById.Values.Where(account => account.ActiveCharacterId == characterId)) + user.ActiveCharacterId = null; + + TouchCampaignLocked(campaignId); + } + + private static IReadOnlyList ParseRoles(string serializedRoles) + { + return NormalizeRoles(serializedRoles.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)); + } + + private static string SerializeRoles(IReadOnlyList roles) + { + return string.Join(",", NormalizeRoles(roles)); + } + + private static string[] NormalizeRoles(IEnumerable roles) + { + return roles + .Where(role => !string.IsNullOrWhiteSpace(role)) + .Select(role => role.Trim().ToLowerInvariant()) + .Distinct(StringComparer.Ordinal) + .OrderBy(role => role, StringComparer.Ordinal) + .ToArray(); + } + + private static bool UserHasRoleLocked(UserAccount user, string role) + { + return ParseRoles(user.Roles).Contains(role, StringComparer.OrdinalIgnoreCase); + } + private UserSession CreateSession(Guid userId) { var token = Guid.NewGuid().ToString("N"); @@ -915,9 +1132,9 @@ public sealed class GameService : IGameService return m_UsersById.GetValueOrDefault(session.UserId); } - private void TouchCampaignLocked(Guid campaignId) + private void TouchCampaignLocked(Guid? campaignId) { - if (m_CampaignsById.TryGetValue(campaignId, out var campaign)) + if (campaignId.HasValue && m_CampaignsById.TryGetValue(campaignId.Value, out var campaign)) campaign.Version += 1; } @@ -954,6 +1171,7 @@ public sealed class GameService : IGameService UsernameNormalized = normalizedUsername, PasswordHash = user.PasswordHash, DisplayName = user.DisplayName, + Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : SerializeRoles(ParseRoles(user.Roles)), ActiveCharacterId = user.ActiveCharacterId }; m_UsersById[storedUser.Id] = storedUser; @@ -1022,6 +1240,7 @@ public sealed class GameService : IGameService UsernameNormalized = user.UsernameNormalized, PasswordHash = user.PasswordHash, DisplayName = user.DisplayName, + Roles = user.Roles, ActiveCharacterId = user.ActiveCharacterId }; } diff --git a/RpgRoller/Services/IGameService.cs b/RpgRoller/Services/IGameService.cs index 9674b53..45fe055 100644 --- a/RpgRoller/Services/IGameService.cs +++ b/RpgRoller/Services/IGameService.cs @@ -15,7 +15,11 @@ public interface IGameService ServiceResult CreateCampaign(string sessionToken, string name, string rulesetId); ServiceResult> GetCampaigns(string sessionToken); ServiceResult GetCampaign(string sessionToken, Guid campaignId); + ServiceResult DeleteCampaign(string sessionToken, Guid campaignId); ServiceResult> GetUsernames(string sessionToken); + ServiceResult> GetUsers(string sessionToken); + ServiceResult UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList roles); + ServiceResult DeleteUser(string sessionToken, Guid userId); ServiceResult CreateCharacter(string sessionToken, string name, Guid campaignId); ServiceResult UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId, string? ownerUsername = null);