Add admin roles, user management, and campaign deletion
This commit is contained in:
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.MapCampaignEndpoints();
|
||||
authenticatedApi.MapCharacterEndpoints();
|
||||
authenticatedApi.MapAdminEndpoints();
|
||||
authenticatedApi.MapSkillEndpoints();
|
||||
authenticatedApi.MapStateEventEndpoints();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ internal static class CampaignEndpoints
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,5 +60,6 @@ public enum HomeViewMode
|
||||
{
|
||||
Loading,
|
||||
Anonymous,
|
||||
Workspace
|
||||
Workspace,
|
||||
Admin
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
break;
|
||||
|
||||
case HomeViewMode.Workspace:
|
||||
<Workspace LoggedOut="OnLoggedOutAsync"/>
|
||||
<Workspace LoggedOut="OnLoggedOutAsync" AdminRequested="OnAdminRequested"/>
|
||||
break;
|
||||
|
||||
case HomeViewMode.Admin:
|
||||
<AdminHome LoggedOut="OnLoggedOutAsync" WorkspaceRequested="OnWorkspaceRequested"/>
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,18 @@ public partial class Home
|
||||
ClearStatus();
|
||||
}
|
||||
|
||||
private void OnAdminRequested()
|
||||
{
|
||||
CurrentView = HomeViewMode.Admin;
|
||||
ClearStatus();
|
||||
}
|
||||
|
||||
private void OnWorkspaceRequested()
|
||||
{
|
||||
CurrentView = HomeViewMode.Workspace;
|
||||
ClearStatus();
|
||||
}
|
||||
|
||||
private void OnLoggedOutAsync(string? message)
|
||||
{
|
||||
CurrentView = HomeViewMode.Anonymous;
|
||||
@@ -75,4 +87,4 @@ public partial class Home
|
||||
|
||||
[Inject]
|
||||
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>Add campaign</span>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
class="ghost"
|
||||
disabled="@(IsMutating || IsCreatingCampaign || !CanDeleteCampaign)"
|
||||
@onclick="DeleteCampaignRequested">
|
||||
Delete current campaign
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
|
||||
@@ -92,12 +92,18 @@ public partial class CampaignManagementPanel
|
||||
[Parameter]
|
||||
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
|
||||
|
||||
[Parameter]
|
||||
public bool CanDeleteCampaign { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> CampaignCreated { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback DeleteCampaignRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
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));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -54,6 +54,14 @@
|
||||
role="menuitem"
|
||||
@onclick="SwitchToManagementAsync">Campaign Management
|
||||
</button>
|
||||
@if (IsCurrentUserAdmin)
|
||||
{
|
||||
<button type="button"
|
||||
class="menu-item"
|
||||
role="menuitem"
|
||||
@onclick="OpenAdminAsync">Admin
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -119,8 +127,10 @@
|
||||
IsMutating="IsMutating"
|
||||
OwnerLabel="OwnerLabel"
|
||||
CanEditCharacter="CanEditCharacter"
|
||||
CanDeleteCampaign="CanDeleteSelectedCampaign"
|
||||
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
|
||||
CampaignCreated="OnCampaignCreatedAsync"
|
||||
DeleteCampaignRequested="DeleteSelectedCampaignAsync"
|
||||
CreateCharacterRequested="OpenCreateCharacterModal"
|
||||
EditCharacterRequested="OpenEditCharacterModal"/>
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
@@ -231,6 +232,12 @@ public partial class Workspace : IAsyncDisposable
|
||||
return SwitchScreenAsync("management");
|
||||
}
|
||||
|
||||
private async Task OpenAdminAsync()
|
||||
{
|
||||
IsScreenMenuOpen = false;
|
||||
await AdminRequested.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task SetMobilePanelAsync(string panel)
|
||||
{
|
||||
MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character";
|
||||
@@ -290,7 +297,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
EditCharacterInitialModel = new()
|
||||
{
|
||||
Name = character.Name,
|
||||
CampaignId = character.CampaignId.ToString(),
|
||||
CampaignId = character.CampaignId?.ToString() ?? string.Empty,
|
||||
OwnerUsername = string.Empty
|
||||
};
|
||||
|
||||
@@ -325,6 +332,34 @@ public partial class Workspace : IAsyncDisposable
|
||||
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)
|
||||
{
|
||||
SelectedCharacterId = characterId;
|
||||
@@ -577,7 +612,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
return skill.DiceRollDefinition;
|
||||
|
||||
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)
|
||||
@@ -728,6 +763,9 @@ public partial class Workspace : IAsyncDisposable
|
||||
[Parameter]
|
||||
public EventCallback<string?> LoggedOut { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback AdminRequested { get; set; }
|
||||
|
||||
private string? SelectedCampaignName => SelectedCampaign?.Name;
|
||||
|
||||
private CharacterSummary? SelectedCharacter =>
|
||||
@@ -736,6 +774,12 @@ public partial class Workspace : IAsyncDisposable
|
||||
private bool IsCurrentUserGm =>
|
||||
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 =>
|
||||
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 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 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 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 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);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ public sealed class RpgRollerDbContext : DbContext
|
||||
entity.Property(x => x.UsernameNormalized).IsRequired().HasMaxLength(64);
|
||||
entity.Property(x => x.PasswordHash).IsRequired();
|
||||
entity.Property(x => x.DisplayName).IsRequired().HasMaxLength(128);
|
||||
entity.Property(x => x.Roles).IsRequired().HasMaxLength(256);
|
||||
entity.HasIndex(x => x.UsernameNormalized).IsUnique();
|
||||
});
|
||||
|
||||
|
||||
@@ -19,9 +19,15 @@ public sealed class UserAccount
|
||||
public required string UsernameNormalized { get; init; }
|
||||
public required string PasswordHash { get; set; }
|
||||
public required string DisplayName { get; set; }
|
||||
public required string Roles { get; set; }
|
||||
public Guid? ActiveCharacterId { get; set; }
|
||||
}
|
||||
|
||||
public static class UserRoles
|
||||
{
|
||||
public const string Admin = "admin";
|
||||
}
|
||||
|
||||
public sealed class UserSession
|
||||
{
|
||||
public required string Token { get; init; }
|
||||
@@ -42,7 +48,7 @@ public sealed class Character
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required Guid OwnerUserId { get; set; }
|
||||
public required Guid CampaignId { get; set; }
|
||||
public Guid? CampaignId { 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()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CampaignId")
|
||||
b.Property<Guid?>("CampaignId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
@@ -208,6 +208,11 @@ namespace RpgRoller.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Roles")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
|
||||
@@ -47,6 +47,7 @@ public sealed class GameService : IGameService
|
||||
UsernameNormalized = normalizedUsername,
|
||||
DisplayName = displayName.Trim(),
|
||||
PasswordHash = string.Empty,
|
||||
Roles = m_UsersById.Count == 0 ? UserRoles.Admin : string.Empty,
|
||||
ActiveCharacterId = null
|
||||
};
|
||||
|
||||
@@ -165,11 +166,21 @@ public sealed class GameService : IGameService
|
||||
if (user is null)
|
||||
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));
|
||||
foreach (var character in m_CharactersById.Values.Where(c => c.OwnerUserId == user.Id))
|
||||
campaignIds.Add(character.CampaignId);
|
||||
IEnumerable<Campaign> visibleCampaigns;
|
||||
if (UserHasRoleLocked(user, UserRoles.Admin))
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
@@ -256,9 +372,10 @@ public sealed class GameService : IGameService
|
||||
if (!m_CampaignsById.TryGetValue(campaignId, out var targetCampaign))
|
||||
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
|
||||
|
||||
var sourceCampaign = m_CampaignsById[character.CampaignId];
|
||||
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;
|
||||
if (!isOwner && !isSourceGm && !isTargetGm)
|
||||
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))
|
||||
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))
|
||||
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.");
|
||||
|
||||
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))
|
||||
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.");
|
||||
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
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.");
|
||||
|
||||
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))
|
||||
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.");
|
||||
|
||||
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))
|
||||
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.");
|
||||
|
||||
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))
|
||||
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)
|
||||
{
|
||||
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)
|
||||
@@ -866,11 +1002,14 @@ public sealed class GameService : IGameService
|
||||
|
||||
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];
|
||||
if (campaign.GmUserId == userId)
|
||||
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)
|
||||
@@ -886,10 +1025,88 @@ public sealed class GameService : IGameService
|
||||
return false;
|
||||
}
|
||||
|
||||
campaignId = character.CampaignId;
|
||||
if (!character.CampaignId.HasValue)
|
||||
return false;
|
||||
|
||||
campaignId = character.CampaignId.Value;
|
||||
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)
|
||||
{
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
@@ -915,9 +1132,9 @@ public sealed class GameService : IGameService
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -954,6 +1171,7 @@ public sealed class GameService : IGameService
|
||||
UsernameNormalized = normalizedUsername,
|
||||
PasswordHash = user.PasswordHash,
|
||||
DisplayName = user.DisplayName,
|
||||
Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : SerializeRoles(ParseRoles(user.Roles)),
|
||||
ActiveCharacterId = user.ActiveCharacterId
|
||||
};
|
||||
m_UsersById[storedUser.Id] = storedUser;
|
||||
@@ -1022,6 +1240,7 @@ public sealed class GameService : IGameService
|
||||
UsernameNormalized = user.UsernameNormalized,
|
||||
PasswordHash = user.PasswordHash,
|
||||
DisplayName = user.DisplayName,
|
||||
Roles = user.Roles,
|
||||
ActiveCharacterId = user.ActiveCharacterId
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ public interface IGameService
|
||||
ServiceResult<CampaignDetails> CreateCampaign(string sessionToken, string name, string rulesetId);
|
||||
ServiceResult<IReadOnlyList<CampaignDetails>> GetCampaigns(string sessionToken);
|
||||
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
|
||||
ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId);
|
||||
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> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId, string? ownerUsername = null);
|
||||
|
||||
Reference in New Issue
Block a user