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 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)
|
- 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
|
- 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
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
@@ -188,4 +188,45 @@ public sealed class CampaignApiTests : ApiTestBase
|
|||||||
Assert.Contains(playerCampaignOptions, option => option.Id == firstCampaign.Id);
|
Assert.Contains(playerCampaignOptions, option => option.Id == firstCampaign.Id);
|
||||||
Assert.Contains(playerCampaignOptions, option => option.Id == secondCampaign.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));
|
var adminUnlink = ServiceTestSupport.GetValue(service.UpdateCharacter(adminTwoSession, character.Id, "Admin Unlink", null));
|
||||||
Assert.Null(adminUnlink.CampaignId);
|
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);
|
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) =>
|
group.MapPost("/characters/{characterId:guid}/activate", (Guid characterId, HttpContext context, IGameService game) =>
|
||||||
{
|
{
|
||||||
var result = game.ActivateCharacter(context.GetRequiredSessionToken(), characterId);
|
var result = game.ActivateCharacter(context.GetRequiredSessionToken(), characterId);
|
||||||
|
|||||||
@@ -58,14 +58,24 @@
|
|||||||
<strong>@character.Name</strong>
|
<strong>@character.Name</strong>
|
||||||
<p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p>
|
<p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<div class="management-actions">
|
||||||
class="chip-button"
|
<button type="button"
|
||||||
title="Edit character"
|
class="chip-button"
|
||||||
disabled="@(IsMutating || IsCreatingCampaign || !CanEditCharacter(character))"
|
title="Edit character"
|
||||||
@onclick="() => EditCharacterRequested.InvokeAsync(character)">
|
disabled="@(IsMutating || IsCreatingCampaign || !CanEditCharacter(character))"
|
||||||
<span aria-hidden="true" class="emoji">✏️</span>
|
@onclick="() => EditCharacterRequested.InvokeAsync(character)">
|
||||||
<span class="sr-only">Edit @character.Name</span>
|
<span aria-hidden="true" class="emoji">✏️</span>
|
||||||
</button>
|
<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>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ public partial class CampaignManagementPanel
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
|
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Func<CharacterSummary, bool> CanDeleteCharacter { get; set; } = _ => false;
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public bool CanDeleteCampaign { get; set; }
|
public bool CanDeleteCampaign { get; set; }
|
||||||
|
|
||||||
@@ -109,4 +112,7 @@ public partial class CampaignManagementPanel
|
|||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
|
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<CharacterSummary> DeleteCharacterRequested { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,12 +127,14 @@
|
|||||||
IsMutating="IsMutating"
|
IsMutating="IsMutating"
|
||||||
OwnerLabel="OwnerLabel"
|
OwnerLabel="OwnerLabel"
|
||||||
CanEditCharacter="CanEditCharacter"
|
CanEditCharacter="CanEditCharacter"
|
||||||
|
CanDeleteCharacter="CanDeleteCharacter"
|
||||||
CanDeleteCampaign="CanDeleteSelectedCampaign"
|
CanDeleteCampaign="CanDeleteSelectedCampaign"
|
||||||
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
|
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
|
||||||
CampaignCreated="OnCampaignCreatedAsync"
|
CampaignCreated="OnCampaignCreatedAsync"
|
||||||
DeleteCampaignRequested="DeleteSelectedCampaignAsync"
|
DeleteCampaignRequested="DeleteSelectedCampaignAsync"
|
||||||
CreateCharacterRequested="OpenCreateCharacterModal"
|
CreateCharacterRequested="OpenCreateCharacterModal"
|
||||||
EditCharacterRequested="OpenEditCharacterModal"/>
|
EditCharacterRequested="OpenEditCharacterModal"
|
||||||
|
DeleteCharacterRequested="DeleteCharacterAsync"/>
|
||||||
}
|
}
|
||||||
</div>
|
</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)
|
private async Task SelectCharacterAsync(Guid characterId)
|
||||||
{
|
{
|
||||||
SelectedCharacterId = characterId;
|
SelectedCharacterId = characterId;
|
||||||
@@ -382,6 +411,11 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
return User is not null && (character.OwnerUserId == User.Id || IsCurrentUserGm || IsCurrentUserAdmin);
|
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)
|
private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user)
|
||||||
{
|
{
|
||||||
return user is not null && character.OwnerUserId == user.Id;
|
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)
|
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId)
|
||||||
{
|
{
|
||||||
lock (m_Gate)
|
lock (m_Gate)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public interface IGameService
|
|||||||
|
|
||||||
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId);
|
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId);
|
||||||
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null);
|
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<bool> ActivateCharacter(string sessionToken, Guid characterId);
|
||||||
ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken);
|
ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken);
|
||||||
|
|
||||||
|
|||||||
@@ -705,6 +705,12 @@ select:focus-visible {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.management-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.add-row-button {
|
.add-row-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user