Add owner/admin character deletion in campaign management

This commit is contained in:
2026-02-26 17:56:52 +01:00
parent ac5acd77f0
commit 51d04fcdc5
11 changed files with 174 additions and 9 deletions

View File

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

View File

@@ -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<CreateCampaignRequest, CampaignDetails>(gmClient, "/api/campaigns", new("Deletion Campaign", "d6"));
var ownerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters", new("Owner Character", campaign.Id));
var otherCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(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<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
Assert.Empty(campaignAfterDeletes.Characters);
}
}

View File

@@ -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);
}
}

View File

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

View File

@@ -58,14 +58,24 @@
<strong>@character.Name</strong>
<p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p>
</div>
<button type="button"
class="chip-button"
title="Edit character"
disabled="@(IsMutating || IsCreatingCampaign || !CanEditCharacter(character))"
@onclick="() => EditCharacterRequested.InvokeAsync(character)">
<span aria-hidden="true" class="emoji">✏️</span>
<span class="sr-only">Edit @character.Name</span>
</button>
<div class="management-actions">
<button type="button"
class="chip-button"
title="Edit character"
disabled="@(IsMutating || IsCreatingCampaign || !CanEditCharacter(character))"
@onclick="() => EditCharacterRequested.InvokeAsync(character)">
<span aria-hidden="true" class="emoji">✏️</span>
<span class="sr-only">Edit @character.Name</span>
</button>
<button type="button"
class="chip-button"
title="Delete character"
disabled="@(IsMutating || IsCreatingCampaign || !CanDeleteCharacter(character))"
@onclick="() => DeleteCharacterRequested.InvokeAsync(character)">
<span aria-hidden="true" class="emoji">🗑️</span>
<span class="sr-only">Delete @character.Name</span>
</button>
</div>
</li>
}
</ul>

View File

@@ -92,6 +92,9 @@ public partial class CampaignManagementPanel
[Parameter]
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
[Parameter]
public Func<CharacterSummary, bool> CanDeleteCharacter { get; set; } = _ => false;
[Parameter]
public bool CanDeleteCampaign { get; set; }
@@ -109,4 +112,7 @@ public partial class CampaignManagementPanel
[Parameter]
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
[Parameter]
public EventCallback<CharacterSummary> DeleteCharacterRequested { get; set; }
}

View File

@@ -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"/>
}
</div>

View File

@@ -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<bool>("confirm", $"Delete character '{character.Name}'?");
if (!confirmed)
return;
IsMutating = true;
try
{
_ = await ApiClient.RequestAsync<bool>("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;

View File

@@ -432,6 +432,28 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<bool> DeleteCharacter(string sessionToken, Guid characterId)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
if (!m_CharactersById.TryGetValue(characterId, out var character))
return ServiceResult<bool>.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<bool>.Failure("forbidden", "Only the owner or admin can delete this character.");
DeleteCharacterLocked(characterId);
PersistStateLocked();
return ServiceResult<bool>.Success(true);
}
}
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId)
{
lock (m_Gate)

View File

@@ -24,6 +24,7 @@ public interface IGameService
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId);
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null);
ServiceResult<bool> DeleteCharacter(string sessionToken, Guid characterId);
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken);

View File

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