Add owner/admin character deletion in campaign management
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
<strong>@character.Name</strong>
|
||||
<p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p>
|
||||
</div>
|
||||
<div class="management-actions">
|
||||
<button type="button"
|
||||
class="chip-button"
|
||||
title="Edit character"
|
||||
@@ -66,6 +67,15 @@
|
||||
<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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user