Use sorted username dropdown for character owner editing
This commit is contained in:
@@ -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 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
|
- Skill and skill-group deletion flows
|
||||||
- GM-driven character owner transfer within campaign management flows
|
- GM-driven character owner transfer within campaign management flows
|
||||||
|
- Character owner selection in edit modal backed by existing-username dropdown data
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
@@ -29,4 +29,22 @@ public sealed class AuthApiTests : ApiTestBase
|
|||||||
var invalidLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "wrong-password"));
|
var invalidLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "wrong-password"));
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, invalidLogin.StatusCode);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,4 +56,22 @@ public sealed class ServiceAuthTests
|
|||||||
Assert.True(login.Succeeded);
|
Assert.True(login.Succeeded);
|
||||||
Assert.Equal(2, hasher.HashCalls);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ internal static class CharacterEndpoints
|
|||||||
return ApiResultMapper.ToApiResult(result);
|
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) =>
|
group.MapGet("/characters/current-campaign", (HttpContext context, IGameService game) =>
|
||||||
{
|
{
|
||||||
var result = game.GetCurrentCampaignCharacters(context.GetRequiredSessionToken());
|
var result = game.GetCurrentCampaignCharacters(context.GetRequiredSessionToken());
|
||||||
|
|||||||
@@ -29,7 +29,13 @@
|
|||||||
@if (AllowOwnerEdit)
|
@if (AllowOwnerEdit)
|
||||||
{
|
{
|
||||||
<label for="@OwnerUsernameInputId">Owner username</label>
|
<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))
|
@if (FormState.Errors.TryGetValue("ownerUsername", out var ownerUsernameError))
|
||||||
{
|
{
|
||||||
<p class="field-error">@ownerUsernameError</p>
|
<p class="field-error">@ownerUsernameError</p>
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ public partial class CharacterFormModal
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public bool AllowOwnerEdit { get; set; }
|
public bool AllowOwnerEdit { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IReadOnlyList<string> AvailableUsernames { get; set; } = [];
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback<Guid> CharacterSaved { get; set; }
|
public EventCallback<Guid> CharacterSaved { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,7 @@
|
|||||||
Campaigns="Campaigns"
|
Campaigns="Campaigns"
|
||||||
IsMutating="IsMutating"
|
IsMutating="IsMutating"
|
||||||
AllowOwnerEdit="false"
|
AllowOwnerEdit="false"
|
||||||
|
AvailableUsernames="KnownUsernames"
|
||||||
CharacterSaved="OnCharacterCreatedAsync"
|
CharacterSaved="OnCharacterCreatedAsync"
|
||||||
CancelRequested="CloseCharacterModals"/>
|
CancelRequested="CloseCharacterModals"/>
|
||||||
|
|
||||||
@@ -169,5 +170,6 @@
|
|||||||
Campaigns="Campaigns"
|
Campaigns="Campaigns"
|
||||||
IsMutating="IsMutating"
|
IsMutating="IsMutating"
|
||||||
AllowOwnerEdit="CanEditCharacterOwner"
|
AllowOwnerEdit="CanEditCharacterOwner"
|
||||||
|
AvailableUsernames="KnownUsernames"
|
||||||
CharacterSaved="OnCharacterUpdatedAsync"
|
CharacterSaved="OnCharacterUpdatedAsync"
|
||||||
CancelRequested="CloseCharacterModals"/>
|
CancelRequested="CloseCharacterModals"/>
|
||||||
|
|||||||
@@ -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)
|
private async Task<bool> ReloadAuthenticatedSessionAsync(Guid? preferredCampaignId)
|
||||||
{
|
{
|
||||||
var me = await TryGetMeAsync();
|
var me = await TryGetMeAsync();
|
||||||
@@ -291,8 +305,11 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
ShowCreateCharacterModal = true;
|
ShowCreateCharacterModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenEditCharacterModal(CharacterSummary character)
|
private async Task OpenEditCharacterModal(CharacterSummary character)
|
||||||
{
|
{
|
||||||
|
if (IsCurrentUserGm)
|
||||||
|
await LoadKnownUsernamesAsync();
|
||||||
|
|
||||||
EditingCharacterId = character.Id;
|
EditingCharacterId = character.Id;
|
||||||
EditCharacterInitialModel = new()
|
EditCharacterInitialModel = new()
|
||||||
{
|
{
|
||||||
@@ -659,6 +676,7 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
CampaignLog = [];
|
CampaignLog = [];
|
||||||
SelectedCharacterId = null;
|
SelectedCharacterId = null;
|
||||||
LastRoll = null;
|
LastRoll = null;
|
||||||
|
KnownUsernames = [];
|
||||||
ShowCreateCharacterModal = false;
|
ShowCreateCharacterModal = false;
|
||||||
ShowEditCharacterModal = false;
|
ShowEditCharacterModal = false;
|
||||||
CanEditCharacterOwner = false;
|
CanEditCharacterOwner = false;
|
||||||
@@ -723,6 +741,7 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
private List<RulesetDefinition> Rulesets { get; set; } = [];
|
private List<RulesetDefinition> Rulesets { get; set; } = [];
|
||||||
private Guid? SelectedCharacterId { get; set; }
|
private Guid? SelectedCharacterId { get; set; }
|
||||||
private RollResult? LastRoll { get; set; }
|
private RollResult? LastRoll { get; set; }
|
||||||
|
private List<string> KnownUsernames { get; set; } = [];
|
||||||
private string RollVisibility { get; set; } = "public";
|
private string RollVisibility { get; set; } = "public";
|
||||||
|
|
||||||
private bool IsMutating { get; set; }
|
private bool IsMutating { get; set; }
|
||||||
|
|||||||
@@ -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)
|
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public interface IGameService
|
|||||||
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId);
|
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId);
|
||||||
ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);
|
ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);
|
||||||
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
|
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
|
||||||
|
ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken);
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user