diff --git a/README.md b/README.md index e941361..7ede43e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/RpgRoller.Tests/Api/AuthApiTests.cs b/RpgRoller.Tests/Api/AuthApiTests.cs index 512f540..fc1a173 100644 --- a/RpgRoller.Tests/Api/AuthApiTests.cs +++ b/RpgRoller.Tests/Api/AuthApiTests.cs @@ -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); } -} \ No newline at end of file + + [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>(client, "/api/users/usernames"); + Assert.Equal(["amy", "bob", "zoe"], usernames); + } +} diff --git a/RpgRoller.Tests/Services/ServiceAuthTests.cs b/RpgRoller.Tests/Services/ServiceAuthTests.cs index c42edb1..d68ab92 100644 --- a/RpgRoller.Tests/Services/ServiceAuthTests.cs +++ b/RpgRoller.Tests/Services/ServiceAuthTests.cs @@ -56,4 +56,22 @@ public sealed class ServiceAuthTests Assert.True(login.Succeeded); Assert.Equal(2, hasher.HashCalls); } -} \ No newline at end of file + + [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); + } +} diff --git a/RpgRoller/Api/CharacterEndpoints.cs b/RpgRoller/Api/CharacterEndpoints.cs index a904986..7680f14 100644 --- a/RpgRoller/Api/CharacterEndpoints.cs +++ b/RpgRoller/Api/CharacterEndpoints.cs @@ -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()); diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor index f4daee9..c8239ad 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor +++ b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor @@ -29,7 +29,13 @@ @if (AllowOwnerEdit) { - + @if (FormState.Errors.TryGetValue("ownerUsername", out var ownerUsernameError)) {

@ownerUsernameError

diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor.cs b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor.cs index f654a82..b946d34 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor.cs @@ -104,6 +104,9 @@ public partial class CharacterFormModal [Parameter] public bool AllowOwnerEdit { get; set; } + [Parameter] + public IReadOnlyList AvailableUsernames { get; set; } = []; + [Parameter] public EventCallback CharacterSaved { get; set; } diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor index 517183f..7308ded 100644 --- a/RpgRoller/Components/Pages/Workspace.razor +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -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"/> diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index cb3c189..0ff667c 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -89,6 +89,20 @@ public partial class Workspace : IAsyncDisposable } } + private async Task LoadKnownUsernamesAsync() + { + try + { + var usernames = await ApiClient.RequestAsync>("GET", "/api/users/usernames"); + KnownUsernames = usernames.OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToList(); + } + catch (ApiRequestException ex) + { + KnownUsernames = []; + SetStatus(ex.Message, true); + } + } + private async Task 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 Rulesets { get; set; } = []; private Guid? SelectedCharacterId { get; set; } private RollResult? LastRoll { get; set; } + private List KnownUsernames { get; set; } = []; private string RollVisibility { get; set; } = "public"; private bool IsMutating { get; set; } diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index a51306a..ee52017 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -198,6 +198,19 @@ public sealed class GameService : IGameService } } + public ServiceResult> GetUsernames(string sessionToken) + { + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult>.Failure("unauthorized", "You must be logged in."); + + var usernames = m_UsersById.Values.Select(account => account.Username).OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToArray(); + return ServiceResult>.Success(usernames); + } + } + public ServiceResult CreateCharacter(string sessionToken, string name, Guid campaignId) { if (string.IsNullOrWhiteSpace(name)) diff --git a/RpgRoller/Services/IGameService.cs b/RpgRoller/Services/IGameService.cs index 1523be4..1629500 100644 --- a/RpgRoller/Services/IGameService.cs +++ b/RpgRoller/Services/IGameService.cs @@ -15,6 +15,7 @@ public interface IGameService ServiceResult CreateCampaign(string sessionToken, string name, string rulesetId); ServiceResult> GetCampaigns(string sessionToken); ServiceResult GetCampaign(string sessionToken, Guid campaignId); + ServiceResult> GetUsernames(string sessionToken); ServiceResult CreateCharacter(string sessionToken, string name, Guid campaignId); ServiceResult UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId, string? ownerUsername = null);