Add admin roles, user management, and campaign deletion

This commit is contained in:
2026-02-26 17:15:10 +01:00
parent 3026221cd6
commit 2e2f364c5e
26 changed files with 1127 additions and 31 deletions

View 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;
}
}

View File

@@ -13,7 +13,8 @@ public static class ApiEndpointRegistration
authenticatedApi.MapMeEndpoints();
authenticatedApi.MapCampaignEndpoints();
authenticatedApi.MapCharacterEndpoints();
authenticatedApi.MapAdminEndpoints();
authenticatedApi.MapSkillEndpoints();
authenticatedApi.MapStateEventEndpoints();
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -60,5 +60,6 @@ public enum HomeViewMode
{
Loading,
Anonymous,
Workspace
Workspace,
Admin
}

View File

@@ -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;
}

View File

@@ -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!;
}
}

View 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>

View 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; }
}

View File

@@ -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">

View File

@@ -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; }

View File

@@ -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)
{

View File

@@ -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"/>
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();
});

View File

@@ -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; }
}

View 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
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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)

View File

@@ -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
};
}

View File

@@ -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);