Add admin roles, user management, and campaign deletion
This commit is contained in:
@@ -51,6 +51,8 @@ Gameplay capabilities now include:
|
|||||||
- 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
|
- Character owner selection in edit modal backed by existing-username dropdown data
|
||||||
|
- Role-aware authorization with admin role support (including admin user/role management)
|
||||||
|
- Campaign deletion by campaign owner or admin (unlinks characters from the campaign and clears campaign log entries)
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public sealed class AuthApiTests : ApiTestBase
|
|||||||
|
|
||||||
var registerResult = await RegisterAsync(client, "alice", "Password123", "Alice");
|
var registerResult = await RegisterAsync(client, "alice", "Password123", "Alice");
|
||||||
Assert.Equal("alice", registerResult.Username);
|
Assert.Equal("alice", registerResult.Username);
|
||||||
|
Assert.Contains(registerResult.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
var duplicate = await client.PostAsJsonAsync("/api/auth/register", new RegisterRequest("alice", "Password123", "Alice 2"));
|
var duplicate = await client.PostAsJsonAsync("/api/auth/register", new RegisterRequest("alice", "Password123", "Alice 2"));
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode);
|
||||||
|
|||||||
@@ -103,4 +103,52 @@ public sealed class CampaignApiTests : ApiTestBase
|
|||||||
var receiverActivate = await receiverClient.PostAsync($"/api/characters/{character.Id}/activate", null);
|
var receiverActivate = await receiverClient.PostAsync($"/api/characters/{character.Id}/activate", null);
|
||||||
Assert.Equal(HttpStatusCode.OK, receiverActivate.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, receiverActivate.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AdminUserManagementAndCampaignDeletion_WorkThroughApi()
|
||||||
|
{
|
||||||
|
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 playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
|
||||||
|
var admin = await RegisterAsync(adminClient, "admin3", "Password123", "Admin");
|
||||||
|
var gm = await RegisterAsync(gmClient, "gm3", "Password123", "GM");
|
||||||
|
var player = await RegisterAsync(playerClient, "player3", "Password123", "Player");
|
||||||
|
|
||||||
|
await LoginAsync(adminClient, "admin3", "Password123");
|
||||||
|
await LoginAsync(gmClient, "gm3", "Password123");
|
||||||
|
await LoginAsync(playerClient, "player3", "Password123");
|
||||||
|
|
||||||
|
var adminUsers = await GetAsync<IReadOnlyList<AdminUserSummary>>(adminClient, "/api/admin/users");
|
||||||
|
var adminEntry = adminUsers.Single(user => user.Id == admin.Id);
|
||||||
|
var playerEntry = adminUsers.Single(user => user.Id == player.Id);
|
||||||
|
Assert.Contains(adminEntry.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
|
||||||
|
Assert.Empty(playerEntry.Roles);
|
||||||
|
|
||||||
|
var promotedPlayer = await PutAsync<UpdateUserRolesRequest, AdminUserSummary>(adminClient, $"/api/admin/users/{player.Id}/roles", new([ "admin" ]));
|
||||||
|
Assert.Contains(promotedPlayer.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var campaign = await PostAsync<CreateCampaignRequest, CampaignDetails>(gmClient, "/api/campaigns", new("Disposable Campaign", "d6"));
|
||||||
|
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters", new("Disposable Hero", campaign.Id));
|
||||||
|
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));
|
||||||
|
_ = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
|
||||||
|
|
||||||
|
var deleteCampaign = await adminClient.DeleteAsync($"/api/campaigns/{campaign.Id}");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, deleteCampaign.StatusCode);
|
||||||
|
|
||||||
|
var getDeletedCampaign = await gmClient.GetAsync($"/api/campaigns/{campaign.Id}");
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, getDeletedCampaign.StatusCode);
|
||||||
|
|
||||||
|
var playerCharacters = await GetAsync<IReadOnlyList<CharacterSummary>>(playerClient, "/api/characters");
|
||||||
|
Assert.Single(playerCharacters);
|
||||||
|
Assert.Null(playerCharacters[0].CampaignId);
|
||||||
|
|
||||||
|
var deleteUser = await adminClient.DeleteAsync($"/api/admin/users/{player.Id}");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, deleteUser.StatusCode);
|
||||||
|
|
||||||
|
var usersAfterDelete = await GetAsync<IReadOnlyList<AdminUserSummary>>(adminClient, "/api/admin/users");
|
||||||
|
Assert.DoesNotContain(usersAfterDelete, user => user.Id == player.Id);
|
||||||
|
Assert.Contains(usersAfterDelete, user => user.Id == gm.Id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ public sealed class HostingCoverageTests
|
|||||||
"Breakdown" TEXT NOT NULL,
|
"Breakdown" TEXT NOT NULL,
|
||||||
"TimestampUtc" TEXT NOT NULL
|
"TimestampUtc" TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
INSERT INTO "Users" ("Id", "Username", "UsernameNormalized", "PasswordHash", "DisplayName", "ActiveCharacterId")
|
||||||
|
VALUES ('00000000-0000-0000-0000-000000000001', 'legacy-admin', 'LEGACY-ADMIN', 'hash', 'Legacy Admin', NULL);
|
||||||
""";
|
""";
|
||||||
_ = command.ExecuteNonQuery();
|
_ = command.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
@@ -115,6 +118,35 @@ public sealed class HostingCoverageTests
|
|||||||
|
|
||||||
Assert.Contains("Dice", rollColumns);
|
Assert.Contains("Dice", rollColumns);
|
||||||
|
|
||||||
|
using var usersTableInfoCommand = verifyConnection.CreateCommand();
|
||||||
|
usersTableInfoCommand.CommandText = "PRAGMA table_info('Users');";
|
||||||
|
using var usersTableInfoReader = usersTableInfoCommand.ExecuteReader();
|
||||||
|
var usersColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
while (usersTableInfoReader.Read())
|
||||||
|
usersColumns.Add(usersTableInfoReader.GetString(1));
|
||||||
|
|
||||||
|
Assert.Contains("Roles", usersColumns);
|
||||||
|
|
||||||
|
using var usersRoleCommand = verifyConnection.CreateCommand();
|
||||||
|
usersRoleCommand.CommandText = "SELECT Roles FROM Users WHERE UsernameNormalized = 'LEGACY-ADMIN';";
|
||||||
|
var roles = Convert.ToString(usersRoleCommand.ExecuteScalar());
|
||||||
|
Assert.Equal("admin", roles);
|
||||||
|
|
||||||
|
using var charactersTableInfoCommand = verifyConnection.CreateCommand();
|
||||||
|
charactersTableInfoCommand.CommandText = "PRAGMA table_info('Characters');";
|
||||||
|
using var charactersTableInfoReader = charactersTableInfoCommand.ExecuteReader();
|
||||||
|
var campaignIdNotNull = true;
|
||||||
|
while (charactersTableInfoReader.Read())
|
||||||
|
{
|
||||||
|
if (!string.Equals(charactersTableInfoReader.GetString(1), "CampaignId", StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
campaignIdNotNull = charactersTableInfoReader.GetInt32(3) == 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.False(campaignIdNotNull);
|
||||||
|
|
||||||
using var historyCommand = verifyConnection.CreateCommand();
|
using var historyCommand = verifyConnection.CreateCommand();
|
||||||
historyCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226084000_InitialSchema';";
|
historyCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226084000_InitialSchema';";
|
||||||
var historyCount = Convert.ToInt32(historyCommand.ExecuteScalar());
|
var historyCount = Convert.ToInt32(historyCommand.ExecuteScalar());
|
||||||
@@ -129,5 +161,10 @@ public sealed class HostingCoverageTests
|
|||||||
rollDiceHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226100000_AddRollLogDice';";
|
rollDiceHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226100000_AddRollLogDice';";
|
||||||
var rollDiceHistoryCount = Convert.ToInt32(rollDiceHistoryCommand.ExecuteScalar());
|
var rollDiceHistoryCount = Convert.ToInt32(rollDiceHistoryCommand.ExecuteScalar());
|
||||||
Assert.Equal(1, rollDiceHistoryCount);
|
Assert.Equal(1, rollDiceHistoryCount);
|
||||||
|
|
||||||
|
using var rolesHistoryCommand = verifyConnection.CreateCommand();
|
||||||
|
rolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226160859_AddAuthorizationRolesAndCampaignDeletion';";
|
||||||
|
var rolesHistoryCount = Convert.ToInt32(rolesHistoryCommand.ExecuteScalar());
|
||||||
|
Assert.Equal(1, rolesHistoryCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using RpgRoller.Domain;
|
||||||
|
|
||||||
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
|
public sealed class ServiceAdminAndCampaignDeletionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AdminRoleManagement_RequiresAdminAndProtectsSelf()
|
||||||
|
{
|
||||||
|
using var harness = ServiceTestSupport.CreateHarness();
|
||||||
|
var service = harness.Service;
|
||||||
|
|
||||||
|
var bootstrapAdmin = ServiceTestSupport.GetValue(service.Register("admin", "Password123", "Admin"));
|
||||||
|
_ = ServiceTestSupport.GetValue(service.Register("member", "Password123", "Member"));
|
||||||
|
|
||||||
|
Assert.Contains(bootstrapAdmin.Roles, role => string.Equals(role, UserRoles.Admin, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var adminSession = ServiceTestSupport.GetValue(service.Login("admin", "Password123")).SessionToken;
|
||||||
|
var memberSession = ServiceTestSupport.GetValue(service.Login("member", "Password123")).SessionToken;
|
||||||
|
|
||||||
|
var forbiddenList = service.GetUsers(memberSession);
|
||||||
|
Assert.False(forbiddenList.Succeeded);
|
||||||
|
|
||||||
|
var users = ServiceTestSupport.GetValue(service.GetUsers(adminSession));
|
||||||
|
var memberUser = users.Single(user => string.Equals(user.Username, "member", StringComparison.OrdinalIgnoreCase));
|
||||||
|
Assert.Empty(memberUser.Roles);
|
||||||
|
|
||||||
|
var promoted = ServiceTestSupport.GetValue(service.UpdateUserRoles(adminSession, memberUser.Id, [UserRoles.Admin]));
|
||||||
|
Assert.Contains(promoted.Roles, role => string.Equals(role, UserRoles.Admin, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var selfDemote = service.UpdateUserRoles(adminSession, bootstrapAdmin.Id, Array.Empty<string>());
|
||||||
|
Assert.False(selfDemote.Succeeded);
|
||||||
|
|
||||||
|
var selfDelete = service.DeleteUser(adminSession, bootstrapAdmin.Id);
|
||||||
|
Assert.False(selfDelete.Succeeded);
|
||||||
|
|
||||||
|
var deletedMember = ServiceTestSupport.GetValue(service.DeleteUser(adminSession, memberUser.Id));
|
||||||
|
Assert.True(deletedMember);
|
||||||
|
|
||||||
|
var remainingUsers = ServiceTestSupport.GetValue(service.GetUsers(adminSession));
|
||||||
|
Assert.Single(remainingUsers);
|
||||||
|
Assert.Equal(bootstrapAdmin.Id, remainingUsers[0].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CampaignDeletion_ByOwnerOrAdmin_UnlinksCharactersAndClearsLog()
|
||||||
|
{
|
||||||
|
using var harness = ServiceTestSupport.CreateHarness(4, 5, 6);
|
||||||
|
var service = harness.Service;
|
||||||
|
|
||||||
|
_ = ServiceTestSupport.GetValue(service.Register("admin", "Password123", "Admin"));
|
||||||
|
_ = ServiceTestSupport.GetValue(service.Register("gm", "Password123", "GM"));
|
||||||
|
_ = ServiceTestSupport.GetValue(service.Register("player", "Password123", "Player"));
|
||||||
|
|
||||||
|
var adminSession = ServiceTestSupport.GetValue(service.Login("admin", "Password123")).SessionToken;
|
||||||
|
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||||
|
var playerSession = ServiceTestSupport.GetValue(service.Login("player", "Password123")).SessionToken;
|
||||||
|
|
||||||
|
var ownerDeletedCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Owner Delete", "d6"));
|
||||||
|
var ownerDeleteResult = ServiceTestSupport.GetValue(service.DeleteCampaign(gmSession, ownerDeletedCampaign.Id));
|
||||||
|
Assert.True(ownerDeleteResult);
|
||||||
|
Assert.False(service.GetCampaign(gmSession, ownerDeletedCampaign.Id).Succeeded);
|
||||||
|
|
||||||
|
var adminDeletedCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Admin Delete", "d6"));
|
||||||
|
var playerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(playerSession, "Scout", adminDeletedCampaign.Id));
|
||||||
|
var playerSkill = ServiceTestSupport.GetValue(service.CreateSkill(playerSession, playerCharacter.Id, "Stealth", "2D+1", 1, true));
|
||||||
|
_ = ServiceTestSupport.GetValue(service.RollSkill(playerSession, playerSkill.Id, "public"));
|
||||||
|
|
||||||
|
var forbiddenDelete = service.DeleteCampaign(playerSession, adminDeletedCampaign.Id);
|
||||||
|
Assert.False(forbiddenDelete.Succeeded);
|
||||||
|
|
||||||
|
var adminDelete = ServiceTestSupport.GetValue(service.DeleteCampaign(adminSession, adminDeletedCampaign.Id));
|
||||||
|
Assert.True(adminDelete);
|
||||||
|
Assert.False(service.GetCampaign(gmSession, adminDeletedCampaign.Id).Succeeded);
|
||||||
|
|
||||||
|
var playerCharacters = ServiceTestSupport.GetValue(service.GetOwnCharacters(playerSession));
|
||||||
|
Assert.Single(playerCharacters);
|
||||||
|
Assert.Null(playerCharacters[0].CampaignId);
|
||||||
|
|
||||||
|
using var db = harness.CreateDbContext();
|
||||||
|
Assert.Empty(db.RollLogEntries.Where(entry => entry.CampaignId == adminDeletedCampaign.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
30
RpgRoller/Api/AdminEndpoints.cs
Normal file
30
RpgRoller/Api/AdminEndpoints.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using RpgRoller.Contracts;
|
||||||
|
using RpgRoller.Services;
|
||||||
|
|
||||||
|
namespace RpgRoller.Api;
|
||||||
|
|
||||||
|
internal static class AdminEndpoints
|
||||||
|
{
|
||||||
|
public static RouteGroupBuilder MapAdminEndpoints(this RouteGroupBuilder group)
|
||||||
|
{
|
||||||
|
group.MapGet("/admin/users", (HttpContext context, IGameService game) =>
|
||||||
|
{
|
||||||
|
var result = game.GetUsers(context.GetRequiredSessionToken());
|
||||||
|
return ApiResultMapper.ToApiResult(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPut("/admin/users/{userId:guid}/roles", (Guid userId, UpdateUserRolesRequest request, HttpContext context, IGameService game) =>
|
||||||
|
{
|
||||||
|
var result = game.UpdateUserRoles(context.GetRequiredSessionToken(), userId, request.Roles);
|
||||||
|
return ApiResultMapper.ToApiResult(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapDelete("/admin/users/{userId:guid}", (Guid userId, HttpContext context, IGameService game) =>
|
||||||
|
{
|
||||||
|
var result = game.DeleteUser(context.GetRequiredSessionToken(), userId);
|
||||||
|
return ApiResultMapper.ToApiResult(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ public static class ApiEndpointRegistration
|
|||||||
authenticatedApi.MapMeEndpoints();
|
authenticatedApi.MapMeEndpoints();
|
||||||
authenticatedApi.MapCampaignEndpoints();
|
authenticatedApi.MapCampaignEndpoints();
|
||||||
authenticatedApi.MapCharacterEndpoints();
|
authenticatedApi.MapCharacterEndpoints();
|
||||||
|
authenticatedApi.MapAdminEndpoints();
|
||||||
authenticatedApi.MapSkillEndpoints();
|
authenticatedApi.MapSkillEndpoints();
|
||||||
authenticatedApi.MapStateEventEndpoints();
|
authenticatedApi.MapStateEventEndpoints();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ internal static class CampaignEndpoints
|
|||||||
return ApiResultMapper.ToApiResult(result);
|
return ApiResultMapper.ToApiResult(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.MapDelete("/campaigns/{campaignId:guid}", (Guid campaignId, HttpContext context, IGameService game) =>
|
||||||
|
{
|
||||||
|
var result = game.DeleteCampaign(context.GetRequiredSessionToken(), campaignId);
|
||||||
|
return ApiResultMapper.ToApiResult(result);
|
||||||
|
});
|
||||||
|
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,5 +60,6 @@ public enum HomeViewMode
|
|||||||
{
|
{
|
||||||
Loading,
|
Loading,
|
||||||
Anonymous,
|
Anonymous,
|
||||||
Workspace
|
Workspace,
|
||||||
|
Admin
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case HomeViewMode.Workspace:
|
case HomeViewMode.Workspace:
|
||||||
<Workspace LoggedOut="OnLoggedOutAsync"/>
|
<Workspace LoggedOut="OnLoggedOutAsync" AdminRequested="OnAdminRequested"/>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case HomeViewMode.Admin:
|
||||||
|
<AdminHome LoggedOut="OnLoggedOutAsync" WorkspaceRequested="OnWorkspaceRequested"/>
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,18 @@ public partial class Home
|
|||||||
ClearStatus();
|
ClearStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnAdminRequested()
|
||||||
|
{
|
||||||
|
CurrentView = HomeViewMode.Admin;
|
||||||
|
ClearStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnWorkspaceRequested()
|
||||||
|
{
|
||||||
|
CurrentView = HomeViewMode.Workspace;
|
||||||
|
ClearStatus();
|
||||||
|
}
|
||||||
|
|
||||||
private void OnLoggedOutAsync(string? message)
|
private void OnLoggedOutAsync(string? message)
|
||||||
{
|
{
|
||||||
CurrentView = HomeViewMode.Anonymous;
|
CurrentView = HomeViewMode.Anonymous;
|
||||||
@@ -75,4 +87,4 @@ public partial class Home
|
|||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
76
RpgRoller/Components/Pages/HomeControls/AdminHome.razor
Normal file
76
RpgRoller/Components/Pages/HomeControls/AdminHome.razor
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<div class="rr-app">
|
||||||
|
<main class="management-screen">
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>Admin</h2>
|
||||||
|
</div>
|
||||||
|
@if (CurrentUser is null)
|
||||||
|
{
|
||||||
|
<p class="empty">Loading admin session...</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="muted"><strong>@CurrentUser.DisplayName</strong> (@CurrentUser.Username)</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="inline-actions">
|
||||||
|
<button type="button" class="ghost" disabled="@(IsMutating || IsLoading)" @onclick="BackToWorkspaceAsync">Back to workspace</button>
|
||||||
|
<a href="" class="logout-link" @onclick:preventDefault="true" @onclick="LogoutAsync">Logout</a>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(StatusMessage))
|
||||||
|
{
|
||||||
|
<p class="@(StatusIsError ? "form-error" : "muted")">@StatusMessage</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>User Management</h2>
|
||||||
|
</div>
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="empty">Loading users...</p>
|
||||||
|
}
|
||||||
|
else if (!IsCurrentUserAdmin)
|
||||||
|
{
|
||||||
|
<p class="empty">Admin role is required to manage users.</p>
|
||||||
|
}
|
||||||
|
else if (Users.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="empty">No users found.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul class="management-list">
|
||||||
|
@foreach (var user in Users)
|
||||||
|
{
|
||||||
|
var userIsAdmin = HasAdminRole(user);
|
||||||
|
<li>
|
||||||
|
<div>
|
||||||
|
<strong>@user.Username</strong>
|
||||||
|
<p class="muted">@user.DisplayName</p>
|
||||||
|
<p class="muted">Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))</p>
|
||||||
|
</div>
|
||||||
|
<div class="skill-chip-actions">
|
||||||
|
<button type="button"
|
||||||
|
class="chip-button"
|
||||||
|
disabled="@(IsMutating || user.Id == CurrentUser?.Id)"
|
||||||
|
@onclick="() => ToggleAdminRoleAsync(user)">
|
||||||
|
<span aria-hidden="true" class="emoji">🛡️</span>
|
||||||
|
<span class="sr-only">Toggle admin role for @user.Username</span>
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="chip-button"
|
||||||
|
disabled="@(IsMutating || user.Id == CurrentUser?.Id)"
|
||||||
|
@onclick="() => DeleteUserAsync(user)">
|
||||||
|
<span aria-hidden="true" class="emoji">🗑️</span>
|
||||||
|
<span class="sr-only">Delete user @user.Username</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
170
RpgRoller/Components/Pages/HomeControls/AdminHome.razor.cs
Normal file
170
RpgRoller/Components/Pages/HomeControls/AdminHome.razor.cs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
using RpgRoller.Contracts;
|
||||||
|
using RpgRoller.Domain;
|
||||||
|
|
||||||
|
namespace RpgRoller.Components.Pages.HomeControls;
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public partial class AdminHome
|
||||||
|
{
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await InitializeAsync();
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var me = await ApiClient.RequestAsync<MeResponse>("GET", "/api/me");
|
||||||
|
CurrentUser = me.User;
|
||||||
|
IsCurrentUserAdmin = HasAdminRole(me.User);
|
||||||
|
if (!IsCurrentUserAdmin)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Users = (await ApiClient.RequestAsync<IReadOnlyList<AdminUserSummary>>("GET", "/api/admin/users"))
|
||||||
|
.OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
||||||
|
{
|
||||||
|
await LoggedOut.InvokeAsync("Session expired. Please log in again.");
|
||||||
|
}
|
||||||
|
catch (ApiRequestException ex)
|
||||||
|
{
|
||||||
|
SetStatus(ex.Message, true);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BackToWorkspaceAsync()
|
||||||
|
{
|
||||||
|
await WorkspaceRequested.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LogoutAsync()
|
||||||
|
{
|
||||||
|
if (IsMutating)
|
||||||
|
return;
|
||||||
|
|
||||||
|
IsMutating = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ApiClient.RequestWithoutPayloadAsync("POST", "/api/auth/logout");
|
||||||
|
}
|
||||||
|
catch (ApiRequestException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsMutating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoggedOut.InvokeAsync("Logged out.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToggleAdminRoleAsync(AdminUserSummary user)
|
||||||
|
{
|
||||||
|
if (IsMutating || CurrentUser is null || user.Id == CurrentUser.Id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
IsMutating = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IReadOnlyList<string> roles = HasAdminRole(user) ? Array.Empty<string>() : [UserRoles.Admin];
|
||||||
|
_ = await ApiClient.RequestAsync<AdminUserSummary>(
|
||||||
|
"PUT",
|
||||||
|
$"/api/admin/users/{user.Id}/roles",
|
||||||
|
new UpdateUserRolesRequest(roles));
|
||||||
|
|
||||||
|
await ReloadUsersAsync();
|
||||||
|
SetStatus("User roles updated.", false);
|
||||||
|
}
|
||||||
|
catch (ApiRequestException ex)
|
||||||
|
{
|
||||||
|
SetStatus(ex.Message, true);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsMutating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteUserAsync(AdminUserSummary user)
|
||||||
|
{
|
||||||
|
if (IsMutating || CurrentUser is null || user.Id == CurrentUser.Id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Delete user '{user.Username}'?");
|
||||||
|
if (!confirmed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
IsMutating = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = await ApiClient.RequestAsync<bool>("DELETE", $"/api/admin/users/{user.Id}");
|
||||||
|
await ReloadUsersAsync();
|
||||||
|
SetStatus("User deleted.", false);
|
||||||
|
}
|
||||||
|
catch (ApiRequestException ex)
|
||||||
|
{
|
||||||
|
SetStatus(ex.Message, true);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsMutating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReloadUsersAsync()
|
||||||
|
{
|
||||||
|
Users = (await ApiClient.RequestAsync<IReadOnlyList<AdminUserSummary>>("GET", "/api/admin/users"))
|
||||||
|
.OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasAdminRole(UserSummary user)
|
||||||
|
{
|
||||||
|
return user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasAdminRole(AdminUserSummary user)
|
||||||
|
{
|
||||||
|
return user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetStatus(string message, bool isError)
|
||||||
|
{
|
||||||
|
StatusMessage = message;
|
||||||
|
StatusIsError = isError;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private IJSRuntime JS { get; set; } = null!;
|
||||||
|
|
||||||
|
private bool IsLoading { get; set; } = true;
|
||||||
|
private bool IsMutating { get; set; }
|
||||||
|
private bool IsCurrentUserAdmin { get; set; }
|
||||||
|
private UserSummary? CurrentUser { get; set; }
|
||||||
|
private List<AdminUserSummary> Users { get; set; } = [];
|
||||||
|
private string? StatusMessage { get; set; }
|
||||||
|
private bool StatusIsError { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<string?> LoggedOut { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback WorkspaceRequested { get; set; }
|
||||||
|
}
|
||||||
@@ -25,6 +25,13 @@
|
|||||||
<span class="add-row-icon" aria-hidden="true">+</span>
|
<span class="add-row-icon" aria-hidden="true">+</span>
|
||||||
<span>Add campaign</span>
|
<span>Add campaign</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
class="ghost"
|
||||||
|
disabled="@(IsMutating || IsCreatingCampaign || !CanDeleteCampaign)"
|
||||||
|
@onclick="DeleteCampaignRequested">
|
||||||
|
Delete current campaign
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
|||||||
@@ -92,12 +92,18 @@ public partial class CampaignManagementPanel
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
|
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public bool CanDeleteCampaign { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
|
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback<Guid> CampaignCreated { get; set; }
|
public EventCallback<Guid> CampaignCreated { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback DeleteCampaignRequested { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback CreateCharacterRequested { get; set; }
|
public EventCallback CreateCharacterRequested { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,13 @@ public partial class CharacterFormModal
|
|||||||
character = await ApiClient.RequestAsync<CharacterSummary>("POST", "/api/characters", new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
|
character = await ApiClient.RequestAsync<CharacterSummary>("POST", "/api/characters", new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
|
||||||
}
|
}
|
||||||
|
|
||||||
await CharacterSaved.InvokeAsync(character.CampaignId);
|
if (!character.CampaignId.HasValue)
|
||||||
|
{
|
||||||
|
FormState.ErrorMessage = "Character must belong to a campaign.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CharacterSaved.InvokeAsync(character.CampaignId.Value);
|
||||||
}
|
}
|
||||||
catch (ApiRequestException ex)
|
catch (ApiRequestException ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,6 +54,14 @@
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
@onclick="SwitchToManagementAsync">Campaign Management
|
@onclick="SwitchToManagementAsync">Campaign Management
|
||||||
</button>
|
</button>
|
||||||
|
@if (IsCurrentUserAdmin)
|
||||||
|
{
|
||||||
|
<button type="button"
|
||||||
|
class="menu-item"
|
||||||
|
role="menuitem"
|
||||||
|
@onclick="OpenAdminAsync">Admin
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -119,8 +127,10 @@
|
|||||||
IsMutating="IsMutating"
|
IsMutating="IsMutating"
|
||||||
OwnerLabel="OwnerLabel"
|
OwnerLabel="OwnerLabel"
|
||||||
CanEditCharacter="CanEditCharacter"
|
CanEditCharacter="CanEditCharacter"
|
||||||
|
CanDeleteCampaign="CanDeleteSelectedCampaign"
|
||||||
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
|
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
|
||||||
CampaignCreated="OnCampaignCreatedAsync"
|
CampaignCreated="OnCampaignCreatedAsync"
|
||||||
|
DeleteCampaignRequested="DeleteSelectedCampaignAsync"
|
||||||
CreateCharacterRequested="OpenCreateCharacterModal"
|
CreateCharacterRequested="OpenCreateCharacterModal"
|
||||||
EditCharacterRequested="OpenEditCharacterModal"/>
|
EditCharacterRequested="OpenEditCharacterModal"/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
|
|||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.JSInterop;
|
using Microsoft.JSInterop;
|
||||||
using RpgRoller.Contracts;
|
using RpgRoller.Contracts;
|
||||||
|
using RpgRoller.Domain;
|
||||||
|
|
||||||
namespace RpgRoller.Components.Pages;
|
namespace RpgRoller.Components.Pages;
|
||||||
|
|
||||||
@@ -231,6 +232,12 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
return SwitchScreenAsync("management");
|
return SwitchScreenAsync("management");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task OpenAdminAsync()
|
||||||
|
{
|
||||||
|
IsScreenMenuOpen = false;
|
||||||
|
await AdminRequested.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SetMobilePanelAsync(string panel)
|
private async Task SetMobilePanelAsync(string panel)
|
||||||
{
|
{
|
||||||
MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character";
|
MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character";
|
||||||
@@ -290,7 +297,7 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
EditCharacterInitialModel = new()
|
EditCharacterInitialModel = new()
|
||||||
{
|
{
|
||||||
Name = character.Name,
|
Name = character.Name,
|
||||||
CampaignId = character.CampaignId.ToString(),
|
CampaignId = character.CampaignId?.ToString() ?? string.Empty,
|
||||||
OwnerUsername = string.Empty
|
OwnerUsername = string.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -325,6 +332,34 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
SetStatus("Character updated.", false);
|
SetStatus("Character updated.", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task DeleteSelectedCampaignAsync()
|
||||||
|
{
|
||||||
|
if (SelectedCampaign is null || IsMutating || !CanDeleteSelectedCampaign)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Delete campaign '{SelectedCampaign.Name}'?");
|
||||||
|
if (!confirmed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
IsMutating = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = await ApiClient.RequestAsync<bool>("DELETE", $"/api/campaigns/{SelectedCampaign.Id}");
|
||||||
|
await ReloadCampaignsAsync(null);
|
||||||
|
await RefreshCampaignScopeAsync();
|
||||||
|
await SyncStateEventsAsync();
|
||||||
|
SetStatus("Campaign 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;
|
||||||
@@ -577,7 +612,7 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
return skill.DiceRollDefinition;
|
return skill.DiceRollDefinition;
|
||||||
|
|
||||||
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
|
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
|
||||||
return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumbleLabel}";
|
return $"{skill.DiceRollDefinition}, wild {skill.WildDice}, {fumbleLabel}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private string RollerLabel(CampaignLogEntry entry)
|
private string RollerLabel(CampaignLogEntry entry)
|
||||||
@@ -728,6 +763,9 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback<string?> LoggedOut { get; set; }
|
public EventCallback<string?> LoggedOut { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback AdminRequested { get; set; }
|
||||||
|
|
||||||
private string? SelectedCampaignName => SelectedCampaign?.Name;
|
private string? SelectedCampaignName => SelectedCampaign?.Name;
|
||||||
|
|
||||||
private CharacterSummary? SelectedCharacter =>
|
private CharacterSummary? SelectedCharacter =>
|
||||||
@@ -736,6 +774,12 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
private bool IsCurrentUserGm =>
|
private bool IsCurrentUserGm =>
|
||||||
SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
|
SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
|
||||||
|
|
||||||
|
private bool IsCurrentUserAdmin =>
|
||||||
|
User is not null && User.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private bool CanDeleteSelectedCampaign =>
|
||||||
|
SelectedCampaign is not null && User is not null && (SelectedCampaign.Gm.Id == User.Id || IsCurrentUserAdmin);
|
||||||
|
|
||||||
private bool IsSelectedCampaignD6 =>
|
private bool IsSelectedCampaignD6 =>
|
||||||
string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
|
string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ public sealed record RegisterRequest(string Username, string Password, string Di
|
|||||||
|
|
||||||
public sealed record LoginRequest(string Username, string Password);
|
public sealed record LoginRequest(string Username, string Password);
|
||||||
|
|
||||||
public sealed record UserSummary(Guid Id, string Username, string DisplayName);
|
public sealed record UserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles);
|
||||||
|
|
||||||
public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId);
|
public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId);
|
||||||
|
|
||||||
|
public sealed record AdminUserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles);
|
||||||
|
|
||||||
|
public sealed record UpdateUserRolesRequest(IReadOnlyList<string> Roles);
|
||||||
|
|
||||||
public sealed record RulesetDefinition(string Id, string Name, string DiceSyntax);
|
public sealed record RulesetDefinition(string Id, string Name, string DiceSyntax);
|
||||||
|
|
||||||
public sealed record CreateCampaignRequest(string Name, string RulesetId);
|
public sealed record CreateCampaignRequest(string Name, string RulesetId);
|
||||||
@@ -22,7 +26,7 @@ public sealed record CreateCharacterRequest(string Name, Guid CampaignId);
|
|||||||
|
|
||||||
public sealed record UpdateCharacterRequest(string Name, Guid CampaignId, string? OwnerUsername = null);
|
public sealed record UpdateCharacterRequest(string Name, Guid CampaignId, string? OwnerUsername = null);
|
||||||
|
|
||||||
public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid CampaignId);
|
public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid? CampaignId);
|
||||||
|
|
||||||
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null);
|
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null);
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public sealed class RpgRollerDbContext : DbContext
|
|||||||
entity.Property(x => x.UsernameNormalized).IsRequired().HasMaxLength(64);
|
entity.Property(x => x.UsernameNormalized).IsRequired().HasMaxLength(64);
|
||||||
entity.Property(x => x.PasswordHash).IsRequired();
|
entity.Property(x => x.PasswordHash).IsRequired();
|
||||||
entity.Property(x => x.DisplayName).IsRequired().HasMaxLength(128);
|
entity.Property(x => x.DisplayName).IsRequired().HasMaxLength(128);
|
||||||
|
entity.Property(x => x.Roles).IsRequired().HasMaxLength(256);
|
||||||
entity.HasIndex(x => x.UsernameNormalized).IsUnique();
|
entity.HasIndex(x => x.UsernameNormalized).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,15 @@ public sealed class UserAccount
|
|||||||
public required string UsernameNormalized { get; init; }
|
public required string UsernameNormalized { get; init; }
|
||||||
public required string PasswordHash { get; set; }
|
public required string PasswordHash { get; set; }
|
||||||
public required string DisplayName { get; set; }
|
public required string DisplayName { get; set; }
|
||||||
|
public required string Roles { get; set; }
|
||||||
public Guid? ActiveCharacterId { get; set; }
|
public Guid? ActiveCharacterId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class UserRoles
|
||||||
|
{
|
||||||
|
public const string Admin = "admin";
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class UserSession
|
public sealed class UserSession
|
||||||
{
|
{
|
||||||
public required string Token { get; init; }
|
public required string Token { get; init; }
|
||||||
@@ -42,7 +48,7 @@ public sealed class Character
|
|||||||
{
|
{
|
||||||
public required Guid Id { get; init; }
|
public required Guid Id { get; init; }
|
||||||
public required Guid OwnerUserId { get; set; }
|
public required Guid OwnerUserId { get; set; }
|
||||||
public required Guid CampaignId { get; set; }
|
public Guid? CampaignId { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
258
RpgRoller/Migrations/20260226160859_AddAuthorizationRolesAndCampaignDeletion.Designer.cs
generated
Normal file
258
RpgRoller/Migrations/20260226160859_AddAuthorizationRolesAndCampaignDeletion.Designer.cs
generated
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using RpgRoller.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace RpgRoller.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(RpgRollerDbContext))]
|
||||||
|
[Migration("20260226160859_AddAuthorizationRolesAndCampaignDeletion")]
|
||||||
|
partial class AddAuthorizationRolesAndCampaignDeletion
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("GmUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Ruleset")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("Version")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("GmUserId");
|
||||||
|
|
||||||
|
b.ToTable("Campaigns");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CampaignId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("OwnerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CampaignId");
|
||||||
|
|
||||||
|
b.HasIndex("OwnerUserId");
|
||||||
|
|
||||||
|
b.ToTable("Characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Breakdown")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CampaignId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Dice")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Result")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("RollerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SkillId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("TimestampUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Visibility")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CampaignId");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.HasIndex("RollerUserId");
|
||||||
|
|
||||||
|
b.HasIndex("SkillId");
|
||||||
|
|
||||||
|
b.ToTable("RollLogEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowFumble")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DiceRollDefinition")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SkillGroupId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("WildDice")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.HasIndex("SkillGroupId");
|
||||||
|
|
||||||
|
b.ToTable("Skills");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowFumble")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DiceRollDefinition")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("WildDice")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.ToTable("SkillGroups");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActiveCharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Roles")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UsernameNormalized")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UsernameNormalized")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Token");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Sessions");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace RpgRoller.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAuthorizationRolesAndCampaignDeletion : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Roles",
|
||||||
|
table: "Users",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "admin");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "CampaignId",
|
||||||
|
table: "Characters",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "TEXT");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
UPDATE Users
|
||||||
|
SET Roles = 'admin'
|
||||||
|
WHERE Roles IS NULL OR TRIM(Roles) = '';
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Roles",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "CampaignId",
|
||||||
|
table: "Characters",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "TEXT",
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ namespace RpgRoller.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<Guid>("CampaignId")
|
b.Property<Guid?>("CampaignId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
@@ -208,6 +208,11 @@ namespace RpgRoller.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Roles")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Username")
|
b.Property<string>("Username")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ public sealed class GameService : IGameService
|
|||||||
UsernameNormalized = normalizedUsername,
|
UsernameNormalized = normalizedUsername,
|
||||||
DisplayName = displayName.Trim(),
|
DisplayName = displayName.Trim(),
|
||||||
PasswordHash = string.Empty,
|
PasswordHash = string.Empty,
|
||||||
|
Roles = m_UsersById.Count == 0 ? UserRoles.Admin : string.Empty,
|
||||||
ActiveCharacterId = null
|
ActiveCharacterId = null
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,11 +166,21 @@ public sealed class GameService : IGameService
|
|||||||
if (user is null)
|
if (user is null)
|
||||||
return ServiceResult<IReadOnlyList<CampaignDetails>>.Failure("unauthorized", "You must be logged in.");
|
return ServiceResult<IReadOnlyList<CampaignDetails>>.Failure("unauthorized", "You must be logged in.");
|
||||||
|
|
||||||
var campaignIds = new HashSet<Guid>(m_CampaignsById.Values.Where(c => c.GmUserId == user.Id).Select(c => c.Id));
|
IEnumerable<Campaign> visibleCampaigns;
|
||||||
foreach (var character in m_CharactersById.Values.Where(c => c.OwnerUserId == user.Id))
|
if (UserHasRoleLocked(user, UserRoles.Admin))
|
||||||
campaignIds.Add(character.CampaignId);
|
{
|
||||||
|
visibleCampaigns = m_CampaignsById.Values;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var campaignIds = new HashSet<Guid>(m_CampaignsById.Values.Where(campaign => campaign.GmUserId == user.Id).Select(campaign => campaign.Id));
|
||||||
|
foreach (var character in m_CharactersById.Values.Where(character => character.OwnerUserId == user.Id && character.CampaignId.HasValue))
|
||||||
|
campaignIds.Add(character.CampaignId!.Value);
|
||||||
|
|
||||||
var results = campaignIds.Select(id => m_CampaignsById[id]).OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).Select(ToCampaignDetails).ToArray();
|
visibleCampaigns = campaignIds.Select(campaignId => m_CampaignsById[campaignId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = visibleCampaigns.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).Select(ToCampaignDetails).ToArray();
|
||||||
|
|
||||||
return ServiceResult<IReadOnlyList<CampaignDetails>>.Success(results);
|
return ServiceResult<IReadOnlyList<CampaignDetails>>.Success(results);
|
||||||
}
|
}
|
||||||
@@ -196,6 +207,26 @@ public sealed class GameService : IGameService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId)
|
||||||
|
{
|
||||||
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
|
if (user is null)
|
||||||
|
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
|
||||||
|
|
||||||
|
if (!m_CampaignsById.TryGetValue(campaignId, out var campaign))
|
||||||
|
return ServiceResult<bool>.Failure("campaign_not_found", "Campaign was not found.");
|
||||||
|
|
||||||
|
if (campaign.GmUserId != user.Id && !UserHasRoleLocked(user, UserRoles.Admin))
|
||||||
|
return ServiceResult<bool>.Failure("forbidden", "Only the campaign owner or admin can delete this campaign.");
|
||||||
|
|
||||||
|
DeleteCampaignLocked(campaignId);
|
||||||
|
PersistStateLocked();
|
||||||
|
return ServiceResult<bool>.Success(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken)
|
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken)
|
||||||
{
|
{
|
||||||
lock (m_Gate)
|
lock (m_Gate)
|
||||||
@@ -209,6 +240,91 @@ public sealed class GameService : IGameService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken)
|
||||||
|
{
|
||||||
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
|
if (user is null)
|
||||||
|
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("unauthorized", "You must be logged in.");
|
||||||
|
|
||||||
|
if (!UserHasRoleLocked(user, UserRoles.Admin))
|
||||||
|
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("forbidden", "Admin role is required.");
|
||||||
|
|
||||||
|
var users = m_UsersById.Values
|
||||||
|
.OrderBy(account => account.Username, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(ToAdminUserSummary)
|
||||||
|
.ToArray();
|
||||||
|
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Success(users);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles)
|
||||||
|
{
|
||||||
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
|
if (user is null)
|
||||||
|
return ServiceResult<AdminUserSummary>.Failure("unauthorized", "You must be logged in.");
|
||||||
|
|
||||||
|
if (!UserHasRoleLocked(user, UserRoles.Admin))
|
||||||
|
return ServiceResult<AdminUserSummary>.Failure("forbidden", "Admin role is required.");
|
||||||
|
|
||||||
|
if (!m_UsersById.TryGetValue(userId, out var targetUser))
|
||||||
|
return ServiceResult<AdminUserSummary>.Failure("user_not_found", "User was not found.");
|
||||||
|
|
||||||
|
var normalizedRoles = NormalizeRoles(roles);
|
||||||
|
if (normalizedRoles.Any(role => !string.Equals(role, UserRoles.Admin, StringComparison.Ordinal)))
|
||||||
|
return ServiceResult<AdminUserSummary>.Failure("invalid_role", "Unsupported role.");
|
||||||
|
|
||||||
|
if (user.Id == targetUser.Id && !normalizedRoles.Contains(UserRoles.Admin, StringComparer.Ordinal))
|
||||||
|
return ServiceResult<AdminUserSummary>.Failure("forbidden", "You cannot remove your own admin role.");
|
||||||
|
|
||||||
|
targetUser.Roles = SerializeRoles(normalizedRoles);
|
||||||
|
PersistStateLocked();
|
||||||
|
return ServiceResult<AdminUserSummary>.Success(ToAdminUserSummary(targetUser));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId)
|
||||||
|
{
|
||||||
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
|
if (user is null)
|
||||||
|
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
|
||||||
|
|
||||||
|
if (!UserHasRoleLocked(user, UserRoles.Admin))
|
||||||
|
return ServiceResult<bool>.Failure("forbidden", "Admin role is required.");
|
||||||
|
|
||||||
|
if (user.Id == userId)
|
||||||
|
return ServiceResult<bool>.Failure("forbidden", "You cannot delete your own account.");
|
||||||
|
|
||||||
|
if (!m_UsersById.TryGetValue(userId, out var targetUser))
|
||||||
|
return ServiceResult<bool>.Failure("user_not_found", "User was not found.");
|
||||||
|
|
||||||
|
var gmCampaignIds = m_CampaignsById.Values.Where(campaign => campaign.GmUserId == targetUser.Id).Select(campaign => campaign.Id).ToArray();
|
||||||
|
foreach (var campaignId in gmCampaignIds)
|
||||||
|
DeleteCampaignLocked(campaignId);
|
||||||
|
|
||||||
|
var ownedCharacterIds = m_CharactersById.Values.Where(character => character.OwnerUserId == targetUser.Id).Select(character => character.Id).ToArray();
|
||||||
|
foreach (var characterId in ownedCharacterIds)
|
||||||
|
DeleteCharacterLocked(characterId);
|
||||||
|
|
||||||
|
m_RollLog.RemoveAll(entry => entry.RollerUserId == targetUser.Id);
|
||||||
|
|
||||||
|
var staleSessions = m_SessionsByToken.Values.Where(session => session.UserId == targetUser.Id).Select(session => session.Token).ToArray();
|
||||||
|
foreach (var token in staleSessions)
|
||||||
|
m_SessionsByToken.Remove(token);
|
||||||
|
|
||||||
|
m_UsersById.Remove(targetUser.Id);
|
||||||
|
m_UserIdsByUsername.Remove(targetUser.UsernameNormalized);
|
||||||
|
|
||||||
|
PersistStateLocked();
|
||||||
|
return ServiceResult<bool>.Success(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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))
|
||||||
@@ -256,9 +372,10 @@ public sealed class GameService : IGameService
|
|||||||
if (!m_CampaignsById.TryGetValue(campaignId, out var targetCampaign))
|
if (!m_CampaignsById.TryGetValue(campaignId, out var targetCampaign))
|
||||||
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
|
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
|
||||||
|
|
||||||
var sourceCampaign = m_CampaignsById[character.CampaignId];
|
|
||||||
var isOwner = character.OwnerUserId == user.Id;
|
var isOwner = character.OwnerUserId == user.Id;
|
||||||
var isSourceGm = sourceCampaign.GmUserId == user.Id;
|
var isSourceGm = character.CampaignId.HasValue &&
|
||||||
|
m_CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) &&
|
||||||
|
sourceCampaign.GmUserId == user.Id;
|
||||||
var isTargetGm = targetCampaign.GmUserId == user.Id;
|
var isTargetGm = targetCampaign.GmUserId == user.Id;
|
||||||
if (!isOwner && !isSourceGm && !isTargetGm)
|
if (!isOwner && !isSourceGm && !isTargetGm)
|
||||||
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner or GM can edit this character.");
|
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner or GM can edit this character.");
|
||||||
@@ -344,7 +461,9 @@ public sealed class GameService : IGameService
|
|||||||
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
||||||
return ServiceResult<SkillGroupSummary>.Failure("character_not_found", "Character was not found.");
|
return ServiceResult<SkillGroupSummary>.Failure("character_not_found", "Character was not found.");
|
||||||
|
|
||||||
var campaign = m_CampaignsById[character.CampaignId];
|
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
|
||||||
|
return ServiceResult<SkillGroupSummary>.Failure(campaignError!.Code, campaignError.Message);
|
||||||
|
|
||||||
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
||||||
return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
|
return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
|
||||||
|
|
||||||
@@ -385,7 +504,9 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<SkillGroupSummary>.Failure("skill_group_not_found", "Skill group was not found.");
|
return ServiceResult<SkillGroupSummary>.Failure("skill_group_not_found", "Skill group was not found.");
|
||||||
|
|
||||||
var character = m_CharactersById[group.CharacterId];
|
var character = m_CharactersById[group.CharacterId];
|
||||||
var campaign = m_CampaignsById[character.CampaignId];
|
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
|
||||||
|
return ServiceResult<SkillGroupSummary>.Failure(campaignError!.Code, campaignError.Message);
|
||||||
|
|
||||||
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
||||||
return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
|
return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
|
||||||
|
|
||||||
@@ -416,7 +537,9 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<bool>.Failure("skill_group_not_found", "Skill group was not found.");
|
return ServiceResult<bool>.Failure("skill_group_not_found", "Skill group was not found.");
|
||||||
|
|
||||||
var character = m_CharactersById[group.CharacterId];
|
var character = m_CharactersById[group.CharacterId];
|
||||||
var campaign = m_CampaignsById[character.CampaignId];
|
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
|
||||||
|
return ServiceResult<bool>.Failure(campaignError!.Code, campaignError.Message);
|
||||||
|
|
||||||
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
||||||
return ServiceResult<bool>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
|
return ServiceResult<bool>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
|
||||||
|
|
||||||
@@ -445,7 +568,9 @@ public sealed class GameService : IGameService
|
|||||||
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
||||||
return ServiceResult<SkillSummary>.Failure("character_not_found", "Character was not found.");
|
return ServiceResult<SkillSummary>.Failure("character_not_found", "Character was not found.");
|
||||||
|
|
||||||
var campaign = m_CampaignsById[character.CampaignId];
|
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
|
||||||
|
return ServiceResult<SkillSummary>.Failure(campaignError!.Code, campaignError.Message);
|
||||||
|
|
||||||
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
||||||
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
||||||
|
|
||||||
@@ -491,7 +616,9 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<SkillSummary>.Failure("skill_not_found", "Skill was not found.");
|
return ServiceResult<SkillSummary>.Failure("skill_not_found", "Skill was not found.");
|
||||||
|
|
||||||
var character = m_CharactersById[skill.CharacterId];
|
var character = m_CharactersById[skill.CharacterId];
|
||||||
var campaign = m_CampaignsById[character.CampaignId];
|
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
|
||||||
|
return ServiceResult<SkillSummary>.Failure(campaignError!.Code, campaignError.Message);
|
||||||
|
|
||||||
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
||||||
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
||||||
|
|
||||||
@@ -527,7 +654,9 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<bool>.Failure("skill_not_found", "Skill was not found.");
|
return ServiceResult<bool>.Failure("skill_not_found", "Skill was not found.");
|
||||||
|
|
||||||
var character = m_CharactersById[skill.CharacterId];
|
var character = m_CharactersById[skill.CharacterId];
|
||||||
var campaign = m_CampaignsById[character.CampaignId];
|
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
|
||||||
|
return ServiceResult<bool>.Failure(campaignError!.Code, campaignError.Message);
|
||||||
|
|
||||||
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
||||||
return ServiceResult<bool>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
return ServiceResult<bool>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
||||||
|
|
||||||
@@ -551,7 +680,9 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<RollResult>.Failure("skill_not_found", "Skill was not found.");
|
return ServiceResult<RollResult>.Failure("skill_not_found", "Skill was not found.");
|
||||||
|
|
||||||
var character = m_CharactersById[skill.CharacterId];
|
var character = m_CharactersById[skill.CharacterId];
|
||||||
var campaign = m_CampaignsById[character.CampaignId];
|
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
|
||||||
|
return ServiceResult<RollResult>.Failure(campaignError!.Code, campaignError.Message);
|
||||||
|
|
||||||
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
||||||
return ServiceResult<RollResult>.Failure("forbidden", "Only the owner or GM can roll this skill.");
|
return ServiceResult<RollResult>.Failure("forbidden", "Only the owner or GM can roll this skill.");
|
||||||
|
|
||||||
@@ -795,7 +926,12 @@ public sealed class GameService : IGameService
|
|||||||
|
|
||||||
private static UserSummary ToUserSummary(UserAccount user)
|
private static UserSummary ToUserSummary(UserAccount user)
|
||||||
{
|
{
|
||||||
return new(user.Id, user.Username, user.DisplayName);
|
return new(user.Id, user.Username, user.DisplayName, ParseRoles(user.Roles));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminUserSummary ToAdminUserSummary(UserAccount user)
|
||||||
|
{
|
||||||
|
return new(user.Id, user.Username, user.DisplayName, ParseRoles(user.Roles));
|
||||||
}
|
}
|
||||||
|
|
||||||
private CampaignDetails ToCampaignDetails(Campaign campaign)
|
private CampaignDetails ToCampaignDetails(Campaign campaign)
|
||||||
@@ -866,11 +1002,14 @@ public sealed class GameService : IGameService
|
|||||||
|
|
||||||
private bool CanViewCampaignLocked(Guid userId, Guid campaignId)
|
private bool CanViewCampaignLocked(Guid userId, Guid campaignId)
|
||||||
{
|
{
|
||||||
|
if (m_UsersById.TryGetValue(userId, out var user) && UserHasRoleLocked(user, UserRoles.Admin))
|
||||||
|
return true;
|
||||||
|
|
||||||
var campaign = m_CampaignsById[campaignId];
|
var campaign = m_CampaignsById[campaignId];
|
||||||
if (campaign.GmUserId == userId)
|
if (campaign.GmUserId == userId)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
return m_CharactersById.Values.Any(c => c.CampaignId == campaignId && c.OwnerUserId == userId);
|
return m_CharactersById.Values.Any(c => c.CampaignId.HasValue && c.CampaignId.Value == campaignId && c.OwnerUserId == userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryGetCurrentCampaignIdLocked(UserAccount user, out Guid campaignId)
|
private bool TryGetCurrentCampaignIdLocked(UserAccount user, out Guid campaignId)
|
||||||
@@ -886,10 +1025,88 @@ public sealed class GameService : IGameService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
campaignId = character.CampaignId;
|
if (!character.CampaignId.HasValue)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
campaignId = character.CampaignId.Value;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool TryResolveCharacterCampaignLocked(Character character, out Campaign campaign, out ServiceError? error)
|
||||||
|
{
|
||||||
|
campaign = default!;
|
||||||
|
if (!character.CampaignId.HasValue || !m_CampaignsById.TryGetValue(character.CampaignId.Value, out var resolvedCampaign) || resolvedCampaign is null)
|
||||||
|
{
|
||||||
|
error = new ServiceError("character_not_in_campaign", "Character is not linked to a campaign.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
campaign = resolvedCampaign;
|
||||||
|
error = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteCampaignLocked(Guid campaignId)
|
||||||
|
{
|
||||||
|
if (!m_CampaignsById.Remove(campaignId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var affectedCharacterIds = m_CharactersById.Values.Where(character => character.CampaignId == campaignId).Select(character => character.Id).ToArray();
|
||||||
|
foreach (var characterId in affectedCharacterIds)
|
||||||
|
m_CharactersById[characterId].CampaignId = null;
|
||||||
|
|
||||||
|
m_RollLog.RemoveAll(entry => entry.CampaignId == campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteCharacterLocked(Guid characterId)
|
||||||
|
{
|
||||||
|
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var campaignId = character.CampaignId;
|
||||||
|
m_CharactersById.Remove(characterId);
|
||||||
|
|
||||||
|
var skillGroupIds = m_SkillGroupsById.Values.Where(group => group.CharacterId == characterId).Select(group => group.Id).ToHashSet();
|
||||||
|
foreach (var skillGroupId in skillGroupIds)
|
||||||
|
m_SkillGroupsById.Remove(skillGroupId);
|
||||||
|
|
||||||
|
var skillIds = m_SkillsById.Values.Where(skill => skill.CharacterId == characterId).Select(skill => skill.Id).ToHashSet();
|
||||||
|
foreach (var skillId in skillIds)
|
||||||
|
m_SkillsById.Remove(skillId);
|
||||||
|
|
||||||
|
m_RollLog.RemoveAll(entry => entry.CharacterId == characterId || skillIds.Contains(entry.SkillId));
|
||||||
|
|
||||||
|
foreach (var user in m_UsersById.Values.Where(account => account.ActiveCharacterId == characterId))
|
||||||
|
user.ActiveCharacterId = null;
|
||||||
|
|
||||||
|
TouchCampaignLocked(campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> ParseRoles(string serializedRoles)
|
||||||
|
{
|
||||||
|
return NormalizeRoles(serializedRoles.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SerializeRoles(IReadOnlyList<string> roles)
|
||||||
|
{
|
||||||
|
return string.Join(",", NormalizeRoles(roles));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] NormalizeRoles(IEnumerable<string> roles)
|
||||||
|
{
|
||||||
|
return roles
|
||||||
|
.Where(role => !string.IsNullOrWhiteSpace(role))
|
||||||
|
.Select(role => role.Trim().ToLowerInvariant())
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.OrderBy(role => role, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool UserHasRoleLocked(UserAccount user, string role)
|
||||||
|
{
|
||||||
|
return ParseRoles(user.Roles).Contains(role, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
private UserSession CreateSession(Guid userId)
|
private UserSession CreateSession(Guid userId)
|
||||||
{
|
{
|
||||||
var token = Guid.NewGuid().ToString("N");
|
var token = Guid.NewGuid().ToString("N");
|
||||||
@@ -915,9 +1132,9 @@ public sealed class GameService : IGameService
|
|||||||
return m_UsersById.GetValueOrDefault(session.UserId);
|
return m_UsersById.GetValueOrDefault(session.UserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TouchCampaignLocked(Guid campaignId)
|
private void TouchCampaignLocked(Guid? campaignId)
|
||||||
{
|
{
|
||||||
if (m_CampaignsById.TryGetValue(campaignId, out var campaign))
|
if (campaignId.HasValue && m_CampaignsById.TryGetValue(campaignId.Value, out var campaign))
|
||||||
campaign.Version += 1;
|
campaign.Version += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -954,6 +1171,7 @@ public sealed class GameService : IGameService
|
|||||||
UsernameNormalized = normalizedUsername,
|
UsernameNormalized = normalizedUsername,
|
||||||
PasswordHash = user.PasswordHash,
|
PasswordHash = user.PasswordHash,
|
||||||
DisplayName = user.DisplayName,
|
DisplayName = user.DisplayName,
|
||||||
|
Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : SerializeRoles(ParseRoles(user.Roles)),
|
||||||
ActiveCharacterId = user.ActiveCharacterId
|
ActiveCharacterId = user.ActiveCharacterId
|
||||||
};
|
};
|
||||||
m_UsersById[storedUser.Id] = storedUser;
|
m_UsersById[storedUser.Id] = storedUser;
|
||||||
@@ -1022,6 +1240,7 @@ public sealed class GameService : IGameService
|
|||||||
UsernameNormalized = user.UsernameNormalized,
|
UsernameNormalized = user.UsernameNormalized,
|
||||||
PasswordHash = user.PasswordHash,
|
PasswordHash = user.PasswordHash,
|
||||||
DisplayName = user.DisplayName,
|
DisplayName = user.DisplayName,
|
||||||
|
Roles = user.Roles,
|
||||||
ActiveCharacterId = user.ActiveCharacterId
|
ActiveCharacterId = user.ActiveCharacterId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ public interface IGameService
|
|||||||
ServiceResult<CampaignDetails> CreateCampaign(string sessionToken, string name, string rulesetId);
|
ServiceResult<CampaignDetails> CreateCampaign(string sessionToken, string name, string rulesetId);
|
||||||
ServiceResult<IReadOnlyList<CampaignDetails>> GetCampaigns(string sessionToken);
|
ServiceResult<IReadOnlyList<CampaignDetails>> GetCampaigns(string sessionToken);
|
||||||
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
|
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
|
||||||
|
ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId);
|
||||||
ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken);
|
ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken);
|
||||||
|
ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken);
|
||||||
|
ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles);
|
||||||
|
ServiceResult<bool> DeleteUser(string sessionToken, Guid userId);
|
||||||
|
|
||||||
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