diff --git a/README.md b/README.md index cc85131..c429d3d 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Gameplay capabilities now include: - Campaign deletion by campaign owner or admin (unlinks characters from the campaign and clears campaign log entries) - 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 ## Prerequisites diff --git a/RpgRoller.Tests/Api/CampaignApiTests.cs b/RpgRoller.Tests/Api/CampaignApiTests.cs index 82c2ba0..6bae214 100644 --- a/RpgRoller.Tests/Api/CampaignApiTests.cs +++ b/RpgRoller.Tests/Api/CampaignApiTests.cs @@ -188,4 +188,45 @@ public sealed class CampaignApiTests : ApiTestBase Assert.Contains(playerCampaignOptions, option => option.Id == firstCampaign.Id); Assert.Contains(playerCampaignOptions, option => option.Id == secondCampaign.Id); } + + [Fact] + public async Task CharacterDelete_RequiresOwnerOrAdmin() + { + 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 ownerClient = factory.CreateClient(new() { AllowAutoRedirect = false }); + using var otherClient = factory.CreateClient(new() { AllowAutoRedirect = false }); + + await RegisterAsync(adminClient, "admin-delete", "Password123", "Admin"); + await LoginAsync(adminClient, "admin-delete", "Password123"); + + await RegisterAsync(gmClient, "gm-delete", "Password123", "GM"); + await LoginAsync(gmClient, "gm-delete", "Password123"); + + await RegisterAsync(ownerClient, "owner-delete", "Password123", "Owner"); + await LoginAsync(ownerClient, "owner-delete", "Password123"); + + await RegisterAsync(otherClient, "other-delete", "Password123", "Other"); + await LoginAsync(otherClient, "other-delete", "Password123"); + + var campaign = await PostAsync(gmClient, "/api/campaigns", new("Deletion Campaign", "d6")); + var ownerCharacter = await PostAsync(ownerClient, "/api/characters", new("Owner Character", campaign.Id)); + var otherCharacter = await PostAsync(otherClient, "/api/characters", new("Other Character", campaign.Id)); + + var gmDeleteAttempt = await gmClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}"); + Assert.Equal(HttpStatusCode.BadRequest, gmDeleteAttempt.StatusCode); + + var otherDeleteAttempt = await otherClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}"); + Assert.Equal(HttpStatusCode.BadRequest, otherDeleteAttempt.StatusCode); + + var ownerDelete = await ownerClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}"); + Assert.Equal(HttpStatusCode.OK, ownerDelete.StatusCode); + + var adminDelete = await adminClient.DeleteAsync($"/api/characters/{otherCharacter.Id}"); + Assert.Equal(HttpStatusCode.OK, adminDelete.StatusCode); + + var campaignAfterDeletes = await GetAsync(gmClient, $"/api/campaigns/{campaign.Id}"); + Assert.Empty(campaignAfterDeletes.Characters); + } } diff --git a/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs b/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs index cba59cc..8db0ff7 100644 --- a/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs +++ b/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs @@ -136,4 +136,40 @@ public sealed class ServiceSkillGroupAndOwnershipTests var adminUnlink = ServiceTestSupport.GetValue(service.UpdateCharacter(adminTwoSession, character.Id, "Admin Unlink", null)); Assert.Null(adminUnlink.CampaignId); } + + [Fact] + public void CharacterDelete_AllowsOnlyOwnerOrAdmin() + { + using var harness = ServiceTestSupport.CreateHarness(); + var service = harness.Service; + + service.Register("admin", "Password123", "Admin"); + service.Register("gm", "Password123", "GM"); + service.Register("owner", "Password123", "Owner"); + service.Register("other", "Password123", "Other"); + + var adminSession = ServiceTestSupport.GetValue(service.Login("admin", "Password123")).SessionToken; + var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken; + var ownerSession = ServiceTestSupport.GetValue(service.Login("owner", "Password123")).SessionToken; + var otherSession = ServiceTestSupport.GetValue(service.Login("other", "Password123")).SessionToken; + + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "d6")); + var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Character", campaign.Id)); + var otherCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(otherSession, "Other Character", campaign.Id)); + + var gmDeleteAttempt = service.DeleteCharacter(gmSession, ownerCharacter.Id); + Assert.False(gmDeleteAttempt.Succeeded); + + var otherDeleteAttempt = service.DeleteCharacter(otherSession, ownerCharacter.Id); + Assert.False(otherDeleteAttempt.Succeeded); + + var ownerDelete = ServiceTestSupport.GetValue(service.DeleteCharacter(ownerSession, ownerCharacter.Id)); + Assert.True(ownerDelete); + + var adminDelete = ServiceTestSupport.GetValue(service.DeleteCharacter(adminSession, otherCharacter.Id)); + Assert.True(adminDelete); + + var campaignAfterDeletes = ServiceTestSupport.GetValue(service.GetCampaign(gmSession, campaign.Id)); + Assert.Empty(campaignAfterDeletes.Characters); + } } diff --git a/RpgRoller/Api/CharacterEndpoints.cs b/RpgRoller/Api/CharacterEndpoints.cs index b91f487..d912265 100644 --- a/RpgRoller/Api/CharacterEndpoints.cs +++ b/RpgRoller/Api/CharacterEndpoints.cs @@ -19,6 +19,12 @@ internal static class CharacterEndpoints return ApiResultMapper.ToApiResult(result); }); + group.MapDelete("/characters/{characterId:guid}", (Guid characterId, HttpContext context, IGameService game) => + { + var result = game.DeleteCharacter(context.GetRequiredSessionToken(), characterId); + return ApiResultMapper.ToApiResult(result); + }); + group.MapPost("/characters/{characterId:guid}/activate", (Guid characterId, HttpContext context, IGameService game) => { var result = game.ActivateCharacter(context.GetRequiredSessionToken(), characterId); diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor index e648c29..3aa014b 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor @@ -58,14 +58,24 @@ @character.Name

Owner: @OwnerLabel(character.OwnerUserId)

- +
+ + +
} diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs index 3081b10..ff530e6 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs @@ -92,6 +92,9 @@ public partial class CampaignManagementPanel [Parameter] public Func CanEditCharacter { get; set; } = _ => false; + [Parameter] + public Func CanDeleteCharacter { get; set; } = _ => false; + [Parameter] public bool CanDeleteCampaign { get; set; } @@ -109,4 +112,7 @@ public partial class CampaignManagementPanel [Parameter] public EventCallback EditCharacterRequested { get; set; } + + [Parameter] + public EventCallback DeleteCharacterRequested { get; set; } } diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor index 2abee3f..7bb53a5 100644 --- a/RpgRoller/Components/Pages/Workspace.razor +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -127,12 +127,14 @@ IsMutating="IsMutating" OwnerLabel="OwnerLabel" CanEditCharacter="CanEditCharacter" + CanDeleteCharacter="CanDeleteCharacter" CanDeleteCampaign="CanDeleteSelectedCampaign" CampaignSelectionChanged="OnCampaignSelectionChangedAsync" CampaignCreated="OnCampaignCreatedAsync" DeleteCampaignRequested="DeleteSelectedCampaignAsync" CreateCharacterRequested="OpenCreateCharacterModal" - EditCharacterRequested="OpenEditCharacterModal"/> + EditCharacterRequested="OpenEditCharacterModal" + DeleteCharacterRequested="DeleteCharacterAsync"/> } diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index bb715d1..1375f07 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -371,6 +371,35 @@ public partial class Workspace : IAsyncDisposable } } + private async Task DeleteCharacterAsync(CharacterSummary character) + { + if (IsMutating || !CanDeleteCharacter(character)) + return; + + var confirmed = await JS.InvokeAsync("confirm", $"Delete character '{character.Name}'?"); + if (!confirmed) + return; + + IsMutating = true; + try + { + _ = await ApiClient.RequestAsync("DELETE", $"/api/characters/{character.Id}"); + await ReloadCampaignsAsync(SelectedCampaignId); + await ReloadCharacterCampaignOptionsAsync(); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + SetStatus("Character deleted.", false); + } + catch (ApiRequestException ex) + { + SetStatus(ex.Message, true); + } + finally + { + IsMutating = false; + } + } + private async Task SelectCharacterAsync(Guid characterId) { SelectedCharacterId = characterId; @@ -382,6 +411,11 @@ public partial class Workspace : IAsyncDisposable return User is not null && (character.OwnerUserId == User.Id || IsCurrentUserGm || IsCurrentUserAdmin); } + private bool CanDeleteCharacter(CharacterSummary character) + { + return User is not null && (character.OwnerUserId == User.Id || IsCurrentUserAdmin); + } + private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user) { return user is not null && character.OwnerUserId == user.Id; diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index 6d2243e..fb81387 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -432,6 +432,28 @@ public sealed class GameService : IGameService } } + public ServiceResult DeleteCharacter(string sessionToken, Guid characterId) + { + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!m_CharactersById.TryGetValue(characterId, out var character)) + return ServiceResult.Failure("character_not_found", "Character was not found."); + + var isOwner = character.OwnerUserId == user.Id; + var isAdmin = UserHasRoleLocked(user, UserRoles.Admin); + if (!isOwner && !isAdmin) + return ServiceResult.Failure("forbidden", "Only the owner or admin can delete this character."); + + DeleteCharacterLocked(characterId); + PersistStateLocked(); + return ServiceResult.Success(true); + } + } + public ServiceResult ActivateCharacter(string sessionToken, Guid characterId) { lock (m_Gate) diff --git a/RpgRoller/Services/IGameService.cs b/RpgRoller/Services/IGameService.cs index cf9cb6f..a541ccf 100644 --- a/RpgRoller/Services/IGameService.cs +++ b/RpgRoller/Services/IGameService.cs @@ -24,6 +24,7 @@ public interface IGameService ServiceResult CreateCharacter(string sessionToken, string name, Guid campaignId); ServiceResult UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null); + ServiceResult DeleteCharacter(string sessionToken, Guid characterId); ServiceResult ActivateCharacter(string sessionToken, Guid characterId); ServiceResult> GetOwnCharacters(string sessionToken); diff --git a/RpgRoller/wwwroot/styles.css b/RpgRoller/wwwroot/styles.css index aa83a3d..ec916ab 100644 --- a/RpgRoller/wwwroot/styles.css +++ b/RpgRoller/wwwroot/styles.css @@ -705,6 +705,12 @@ select:focus-visible { margin: 0; } +.management-actions { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + .add-row-button { display: inline-flex; align-items: center;