Use sorted username dropdown for character owner editing

This commit is contained in:
2026-02-26 14:19:58 +01:00
parent 76c83a5784
commit 83151d81fd
10 changed files with 91 additions and 4 deletions

View File

@@ -49,6 +49,7 @@ Gameplay capabilities now include:
- Skill groups per character with skill prototypes (create/edit/delete groups, assign/reassign skills, and prefill new skill forms from group defaults)
- 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
## Prerequisites

View File

@@ -29,4 +29,22 @@ public sealed class AuthApiTests : ApiTestBase
var invalidLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "wrong-password"));
Assert.Equal(HttpStatusCode.BadRequest, invalidLogin.StatusCode);
}
}
[Fact]
public async Task UsernamesEndpoint_RequiresAuthAndReturnsAlphabeticalList()
{
using var factory = CreateFactory();
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(client, "zoe", "Password123", "Zoe");
await RegisterAsync(client, "amy", "Password123", "Amy");
await RegisterAsync(client, "bob", "Password123", "Bob");
var unauthorized = await client.GetAsync("/api/users/usernames");
Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode);
await LoginAsync(client, "bob", "Password123");
var usernames = await GetAsync<IReadOnlyList<string>>(client, "/api/users/usernames");
Assert.Equal(["amy", "bob", "zoe"], usernames);
}
}

View File

@@ -56,4 +56,22 @@ public sealed class ServiceAuthTests
Assert.True(login.Succeeded);
Assert.Equal(2, hasher.HashCalls);
}
}
[Fact]
public void GetUsernames_RequiresAuthAndReturnsSortedUsernames()
{
using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service;
service.Register("zoe", "Password123", "Zoe");
service.Register("amy", "Password123", "Amy");
service.Register("bob", "Password123", "Bob");
var unauthorized = service.GetUsernames(string.Empty);
Assert.False(unauthorized.Succeeded);
var session = ServiceTestSupport.GetValue(service.Login("bob", "Password123")).SessionToken;
var usernames = ServiceTestSupport.GetValue(service.GetUsernames(session));
Assert.Equal(["amy", "bob", "zoe"], usernames);
}
}

View File

@@ -25,6 +25,12 @@ internal static class CharacterEndpoints
return ApiResultMapper.ToApiResult(result);
});
group.MapGet("/users/usernames", (HttpContext context, IGameService game) =>
{
var result = game.GetUsernames(context.GetRequiredSessionToken());
return ApiResultMapper.ToApiResult(result);
});
group.MapGet("/characters/current-campaign", (HttpContext context, IGameService game) =>
{
var result = game.GetCurrentCampaignCharacters(context.GetRequiredSessionToken());

View File

@@ -29,7 +29,13 @@
@if (AllowOwnerEdit)
{
<label for="@OwnerUsernameInputId">Owner username</label>
<input id="@OwnerUsernameInputId" @bind="FormState.Model.OwnerUsername" @bind:event="oninput" placeholder="Leave empty to keep current owner"/>
<select id="@OwnerUsernameInputId" @bind="FormState.Model.OwnerUsername">
<option value="">Keep current owner</option>
@foreach (var username in AvailableUsernames.OrderBy(username => username, StringComparer.OrdinalIgnoreCase))
{
<option value="@username">@username</option>
}
</select>
@if (FormState.Errors.TryGetValue("ownerUsername", out var ownerUsernameError))
{
<p class="field-error">@ownerUsernameError</p>

View File

@@ -104,6 +104,9 @@ public partial class CharacterFormModal
[Parameter]
public bool AllowOwnerEdit { get; set; }
[Parameter]
public IReadOnlyList<string> AvailableUsernames { get; set; } = [];
[Parameter]
public EventCallback<Guid> CharacterSaved { get; set; }

View File

@@ -153,6 +153,7 @@
Campaigns="Campaigns"
IsMutating="IsMutating"
AllowOwnerEdit="false"
AvailableUsernames="KnownUsernames"
CharacterSaved="OnCharacterCreatedAsync"
CancelRequested="CloseCharacterModals"/>
@@ -169,5 +170,6 @@
Campaigns="Campaigns"
IsMutating="IsMutating"
AllowOwnerEdit="CanEditCharacterOwner"
AvailableUsernames="KnownUsernames"
CharacterSaved="OnCharacterUpdatedAsync"
CancelRequested="CloseCharacterModals"/>

View File

@@ -89,6 +89,20 @@ public partial class Workspace : IAsyncDisposable
}
}
private async Task LoadKnownUsernamesAsync()
{
try
{
var usernames = await ApiClient.RequestAsync<IReadOnlyList<string>>("GET", "/api/users/usernames");
KnownUsernames = usernames.OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToList();
}
catch (ApiRequestException ex)
{
KnownUsernames = [];
SetStatus(ex.Message, true);
}
}
private async Task<bool> ReloadAuthenticatedSessionAsync(Guid? preferredCampaignId)
{
var me = await TryGetMeAsync();
@@ -291,8 +305,11 @@ public partial class Workspace : IAsyncDisposable
ShowCreateCharacterModal = true;
}
private void OpenEditCharacterModal(CharacterSummary character)
private async Task OpenEditCharacterModal(CharacterSummary character)
{
if (IsCurrentUserGm)
await LoadKnownUsernamesAsync();
EditingCharacterId = character.Id;
EditCharacterInitialModel = new()
{
@@ -659,6 +676,7 @@ public partial class Workspace : IAsyncDisposable
CampaignLog = [];
SelectedCharacterId = null;
LastRoll = null;
KnownUsernames = [];
ShowCreateCharacterModal = false;
ShowEditCharacterModal = false;
CanEditCharacterOwner = false;
@@ -723,6 +741,7 @@ public partial class Workspace : IAsyncDisposable
private List<RulesetDefinition> Rulesets { get; set; } = [];
private Guid? SelectedCharacterId { get; set; }
private RollResult? LastRoll { get; set; }
private List<string> KnownUsernames { get; set; } = [];
private string RollVisibility { get; set; } = "public";
private bool IsMutating { get; set; }

View File

@@ -198,6 +198,19 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<string>>.Failure("unauthorized", "You must be logged in.");
var usernames = m_UsersById.Values.Select(account => account.Username).OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToArray();
return ServiceResult<IReadOnlyList<string>>.Success(usernames);
}
}
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId)
{
if (string.IsNullOrWhiteSpace(name))

View File

@@ -15,6 +15,7 @@ public interface IGameService
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId);
ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken);
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId);
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId, string? ownerUsername = null);