From 5abe9c320909f041fbc2326b90a8c3dd6da1545d Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Wed, 16 Sep 2026 14:21:03 -0500
Subject: [PATCH 01/55] Scaffolding for new User system
---
.../PMDDesktop.Server.Users.Tests.csproj | 25 ++++
PMDDesktop.Server.Users.Tests/UserTests.cs | 31 +++++
PMDDesktop.Server.Users/AccessToken.cs | 53 ++++++++
PMDDesktop.Server.Users/ISaveDataIndexable.cs | 17 +++
.../PMDDesktop.Server.Users.csproj | 14 ++
.../SaveDataReferenceConverter.cs | 29 +++++
PMDDesktop.Server.Users/User.cs | 120 ++++++++++++++++++
PMDDesktop.Server.Users/UserManager.cs | 12 ++
PMDDesktop.Server.Users/UserReference.cs | 44 +++++++
PMDDesktop.Server/User.cs | 20 ---
PMDDesktop.slnx | 2 +
11 files changed, 347 insertions(+), 20 deletions(-)
create mode 100644 PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj
create mode 100644 PMDDesktop.Server.Users.Tests/UserTests.cs
create mode 100644 PMDDesktop.Server.Users/AccessToken.cs
create mode 100644 PMDDesktop.Server.Users/ISaveDataIndexable.cs
create mode 100644 PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj
create mode 100644 PMDDesktop.Server.Users/SaveDataReferenceConverter.cs
create mode 100644 PMDDesktop.Server.Users/User.cs
create mode 100644 PMDDesktop.Server.Users/UserManager.cs
create mode 100644 PMDDesktop.Server.Users/UserReference.cs
delete mode 100644 PMDDesktop.Server/User.cs
diff --git a/PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj b/PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj
new file mode 100644
index 0000000..c988a3a
--- /dev/null
+++ b/PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs
new file mode 100644
index 0000000..6dd070e
--- /dev/null
+++ b/PMDDesktop.Server.Users.Tests/UserTests.cs
@@ -0,0 +1,31 @@
+namespace PMDDesktop.Server.Users.Tests;
+
+public class UserTests
+{
+
+ [Fact]
+ public async Task PasswordVerification()
+ {
+
+ User user = new();
+
+ await user.SetPassword("Test123");
+
+ Assert.False(await user.VerifyPassword("Test456"));
+ Assert.False(await user.VerifyPassword(""));
+ Assert.True(await user.VerifyPassword("Test123"));
+
+ }
+
+ [Fact]
+ public async Task NullPasswordVerification()
+ {
+
+ User user = new();
+
+ Assert.False(await user.VerifyPassword("Test123"));
+ Assert.False(await user.VerifyPassword("Test456"));
+
+ }
+
+}
diff --git a/PMDDesktop.Server.Users/AccessToken.cs b/PMDDesktop.Server.Users/AccessToken.cs
new file mode 100644
index 0000000..4874c99
--- /dev/null
+++ b/PMDDesktop.Server.Users/AccessToken.cs
@@ -0,0 +1,53 @@
+using System.Security.Cryptography;
+
+namespace PMDDesktop.Server.Users;
+
+public sealed class AccessToken
+{
+
+ public static readonly TimeSpan TIME_UNTIL_EXPIRE = new(8, 0, 0, 0); // 8 days.
+ public static readonly TimeSpan REFRESH_THRESHOLD = new(23, 0, 0); // Ideally if you log in once a week, you'll stay logged in.
+
+ public static readonly int RANDOM_BYTE_COUNT = 32;
+
+ internal AccessToken(string token, User forUser, DateTime expires)
+ {
+
+ TokenString = token;
+ User = new(forUser);
+ ExpiryDate = expires;
+
+ }
+
+ internal static AccessToken CreateNewToken(User forUser)
+ {
+
+ string tokenString = Convert.ToBase64String(RandomNumberGenerator.GetBytes(RANDOM_BYTE_COUNT));
+
+ return new(tokenString, forUser, DateTime.Now + TIME_UNTIL_EXPIRE);
+
+ }
+
+ public DateTime ExpiryDate {get; private set;}
+
+ public string TokenString {get; private set;}
+
+ public UserReference User {get; internal set;}
+
+ public void RefreshToken()
+ {
+
+ ExpiryDate = DateTime.UtcNow + TIME_UNTIL_EXPIRE;
+
+ }
+
+ public bool IsTokenValid()
+ {
+
+ DateTime now = DateTime.UtcNow;
+
+ return now < ExpiryDate;
+
+ }
+
+}
\ No newline at end of file
diff --git a/PMDDesktop.Server.Users/ISaveDataIndexable.cs b/PMDDesktop.Server.Users/ISaveDataIndexable.cs
new file mode 100644
index 0000000..ab2f414
--- /dev/null
+++ b/PMDDesktop.Server.Users/ISaveDataIndexable.cs
@@ -0,0 +1,17 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace PMDDesktop.Server.Users;
+
+///
+/// Interface for objects that can fetch a from a .
+///
+public interface IUserIndexable
+{
+
+
+ User GetUser(Guid GUID);
+
+
+ bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User user);
+
+}
diff --git a/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj b/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj
new file mode 100644
index 0000000..b93788b
--- /dev/null
+++ b/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Library
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/PMDDesktop.Server.Users/SaveDataReferenceConverter.cs b/PMDDesktop.Server.Users/SaveDataReferenceConverter.cs
new file mode 100644
index 0000000..4c76088
--- /dev/null
+++ b/PMDDesktop.Server.Users/SaveDataReferenceConverter.cs
@@ -0,0 +1,29 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace PMDDesktop.Server.Users;
+
+
+internal class UserReferenceConverter : JsonConverter
+{
+ public override UserReference Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+
+ if (reader.TokenType != JsonTokenType.String)
+ throw new JsonException($"{Type} was expecting a string token to deserialize.");
+
+ string guidString = reader.GetString() ?? throw new JsonException($"We read a null string while trying to deserialize {Type}.");
+
+ Guid guid = Guid.Parse(guidString);
+
+ return new UserReference(guid);
+
+ }
+
+ public override void Write(Utf8JsonWriter writer, UserReference value, JsonSerializerOptions options)
+ {
+
+ writer.WriteStringValue(value.GUID.ToString());
+
+ }
+}
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
new file mode 100644
index 0000000..4580fb9
--- /dev/null
+++ b/PMDDesktop.Server.Users/User.cs
@@ -0,0 +1,120 @@
+using System.Text.Json.Serialization;
+using Microsoft.AspNetCore.Identity;
+
+namespace PMDDesktop.Server.Users;
+
+public sealed class User
+{
+
+ static PasswordHasher PASSWORD_HASHER = new();
+
+ [JsonConstructor]
+ internal User()
+ {
+
+ }
+
+ ///
+ /// A string representing a 's name.
+ ///
+ [JsonInclude]
+ public string Name {get; internal set;} = string.Empty;
+
+ ///
+ /// A string to use for logging in. Should only ever contain [a-z], [0-9] and (-)
+ /// Does not neccessarily match in most senarios.
+ /// Should be unique.
+ ///
+ [JsonInclude]
+ public string LoginHandle {get; internal set;} = string.Empty;
+
+ ///
+ /// Hashed password used to log in.
+ /// In the event that it is null, access to this account cannot be granted via password.
+ ///
+ [JsonInclude]
+ internal string? HashedPassword {get; set;}
+
+ ///
+ /// A unique for each . Stays consistant even when the 's handle changes.
+ /// This should be used for long-term references to a specific .
+ ///
+ [JsonIgnore]
+ public Guid GUID {get; internal set;} = Guid.NewGuid();
+
+ ///
+ /// Salt and hash the user's password, then store it in HashedPassword.
+ ///
+ ///
+ ///
+ public async Task SetPassword(string plainText)
+ {
+
+ HashedPassword = PASSWORD_HASHER.HashPassword(this, plainText);
+
+ }
+
+ public async Task VerifyPassword(string plainText)
+ {
+
+ if (HashedPassword is null)
+ {
+
+ return false;
+
+ }
+
+ PasswordVerificationResult result = PASSWORD_HASHER.VerifyHashedPassword(this, HashedPassword, plainText);
+
+ if (result == PasswordVerificationResult.SuccessRehashNeeded)
+ {
+
+ await SetPassword(plainText);
+
+ }
+
+ return result == PasswordVerificationResult.Success || result == PasswordVerificationResult.SuccessRehashNeeded;
+
+ }
+
+ internal string GetUserFolder()
+ {
+
+ return Path.Combine(AppContext.BaseDirectory, "users", GUID.ToString());
+
+ }
+
+ public string GetUserJSON()
+ {
+
+ return Path.Combine(GetUserFolder(), "user.json");
+
+ }
+
+ public async Task WriteNewData()
+ {
+
+
+
+ }
+
+ public override bool Equals(object? obj)
+ {
+
+ if (obj is User user)
+ {
+
+ return user.GUID == GUID;
+
+ }
+
+ return false;
+
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(GUID);
+ }
+
+}
diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs
new file mode 100644
index 0000000..2e50ea4
--- /dev/null
+++ b/PMDDesktop.Server.Users/UserManager.cs
@@ -0,0 +1,12 @@
+namespace PMDDesktop.Server.Users;
+
+public sealed class UserManager()
+{
+
+ private Dictionary users = [];
+
+ private Dictionary tokens = [];
+
+
+
+}
\ No newline at end of file
diff --git a/PMDDesktop.Server.Users/UserReference.cs b/PMDDesktop.Server.Users/UserReference.cs
new file mode 100644
index 0000000..d338b14
--- /dev/null
+++ b/PMDDesktop.Server.Users/UserReference.cs
@@ -0,0 +1,44 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+
+namespace PMDDesktop.Server.Users;
+
+[JsonConverter(typeof(UserReferenceConverter))]
+public sealed class UserReference
+{
+
+ public Guid GUID { get; private set; }
+
+
+ public UserReference(User initalValue)
+ {
+
+ GUID = initalValue.GUID;
+
+ }
+
+
+ internal UserReference(Guid directGuid)
+ {
+
+ GUID = directGuid;
+
+ }
+
+
+ public User GetUser(IUserIndexable indexable)
+ {
+
+ return indexable.GetUser(GUID);
+
+ }
+
+
+ public bool TryGetUser(IUserIndexable indexable, [NotNullWhen(true)] out User user)
+ {
+
+ return indexable.TryGetUser(GUID, out user);
+
+ }
+
+}
diff --git a/PMDDesktop.Server/User.cs b/PMDDesktop.Server/User.cs
deleted file mode 100644
index 7435846..0000000
--- a/PMDDesktop.Server/User.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using PMDDesktop.Server.Saving;
-
-namespace PMDDesktop.Server;
-
-[SaveSubdirectory("users")]
-internal sealed class User : SaveData
-{
-
- public string displayName;
- public string loginHandle;
-
- private User(string handle) : base()
- {
-
- loginHandle = handle;
- displayName = handle.ToUpper();
-
- }
-
-}
diff --git a/PMDDesktop.slnx b/PMDDesktop.slnx
index 12cfc68..c3e0818 100644
--- a/PMDDesktop.slnx
+++ b/PMDDesktop.slnx
@@ -2,6 +2,8 @@
+
+
From 5259007d847587050c61e6d0554577df2879ce8d Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Sun, 20 Sep 2026 14:21:17 -0500
Subject: [PATCH 02/55] Change JsonRequestHandler's CreateResponse to be async
---
PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs | 4 ++--
.../RequestHandlers/ListSpeciesRequestHandler.cs | 2 +-
PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs
index 687ee38..7ae343f 100644
--- a/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs
+++ b/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs
@@ -163,7 +163,7 @@ public async Task HandleRequest(HttpContext context, GameServer game)
context.Response.StatusCode = 200;
context.Response.ContentType = "application/json";
- await context.Response.WriteAsJsonAsync(CreateResponse(deserialized, game), AppInfo.NETWORK_JSON_OPTIONS);
+ await context.Response.WriteAsJsonAsync(await CreateResponse(deserialized, game), AppInfo.NETWORK_JSON_OPTIONS);
}
catch (UserRequestException e) // Intended for user-facing errors, like bad inputs,
@@ -200,6 +200,6 @@ private static async Task WritePlainText(HttpContext context, int code, string e
}
- protected abstract TRes CreateResponse(TReq request, GameServer game);
+ protected abstract Task CreateResponse(TReq request, GameServer game);
}
diff --git a/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs
index 3cbab7b..4e1cc57 100644
--- a/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs
+++ b/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs
@@ -7,7 +7,7 @@ namespace PMDDesktop.Server.RequestHandlers;
public class ListSpeciesRequestHandler : JsonRequestHandler
{
- protected override ListSpeciesResponse CreateResponse(ListSpeciesRequest request, GameServer game)
+ protected override async Task CreateResponse(ListSpeciesRequest request, GameServer game)
{
return new()
diff --git a/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs
index 066f471..6b43b34 100644
--- a/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs
+++ b/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs
@@ -7,7 +7,7 @@ namespace PMDDesktop.Server.RequestHandlers;
public class ListTypesRequestHandler : JsonRequestHandler
{
- protected override ListTypesResponse CreateResponse(ListTypesRequest request, GameServer game)
+ protected override async Task CreateResponse(ListTypesRequest request, GameServer game)
{
return new()
From 12b13a9d3666d00be26bbd28f1952e926da5e548 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Sun, 20 Sep 2026 22:37:40 -0500
Subject: [PATCH 03/55] Add login and access token distributor
---
PMDDesktop.Server.Users.Tests/UserTests.cs | 76 ++++++-
PMDDesktop.Server.Users/AccessToken.cs | 53 -----
...SaveDataIndexable.cs => IUserIndexable.cs} | 6 +-
PMDDesktop.Server.Users/User.cs | 193 +++++++++++++++++-
PMDDesktop.Server.Users/UserAccessToken.cs | 76 +++++++
.../UserCreationOptions.cs | 10 +
PMDDesktop.Server.Users/UserManager.cs | 128 +++++++++++-
PMDDesktop.Server.Users/UserReference.cs | 14 +-
...Converter.cs => UserReferenceConverter.cs} | 0
PMDDesktop.Server/Game/GameState.cs | 2 +
PMDDesktop.Server/PMDDesktop.Server.csproj | 1 +
.../Auth/LoginRequestHandler.cs | 26 +++
PMDDesktop/Requests/Auth/LoginRequest.cs | 10 +
PMDDesktop/Requests/Auth/LoginResponse.cs | 8 +
14 files changed, 520 insertions(+), 83 deletions(-)
delete mode 100644 PMDDesktop.Server.Users/AccessToken.cs
rename PMDDesktop.Server.Users/{ISaveDataIndexable.cs => IUserIndexable.cs} (72%)
create mode 100644 PMDDesktop.Server.Users/UserAccessToken.cs
create mode 100644 PMDDesktop.Server.Users/UserCreationOptions.cs
rename PMDDesktop.Server.Users/{SaveDataReferenceConverter.cs => UserReferenceConverter.cs} (100%)
create mode 100644 PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs
create mode 100644 PMDDesktop/Requests/Auth/LoginRequest.cs
create mode 100644 PMDDesktop/Requests/Auth/LoginResponse.cs
diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs
index 6dd070e..6e8751a 100644
--- a/PMDDesktop.Server.Users.Tests/UserTests.cs
+++ b/PMDDesktop.Server.Users.Tests/UserTests.cs
@@ -3,17 +3,30 @@
public class UserTests
{
+ public static readonly UserCreationOptions DEFAULT_USER_OPTIONS = new()
+ {
+ DisplayName = "Test User",
+ LoginHandle = "test",
+ PlainTextPassword = "Test123"
+ };
+
[Fact]
public async Task PasswordVerification()
{
- User user = new();
+ UserManager manager = new();
+ Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS));
+
+ string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle;
+ Assert.NotNull(loginHandle);
+ Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user));
- await user.SetPassword("Test123");
+ string? defaultPassword = DEFAULT_USER_OPTIONS.PlainTextPassword;
+ Assert.NotNull(defaultPassword);
Assert.False(await user.VerifyPassword("Test456"));
Assert.False(await user.VerifyPassword(""));
- Assert.True(await user.VerifyPassword("Test123"));
+ Assert.True(await user.VerifyPassword(defaultPassword));
}
@@ -21,11 +34,64 @@ public async Task PasswordVerification()
public async Task NullPasswordVerification()
{
- User user = new();
+ UserManager manager = new();
+ Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS with
+ {
+ PlainTextPassword = null
+ }));
+
+ string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle;
+ Assert.NotNull(loginHandle);
+ Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user));
- Assert.False(await user.VerifyPassword("Test123"));
+ string? defaultPassword = DEFAULT_USER_OPTIONS.PlainTextPassword;
+ Assert.NotNull(defaultPassword);
+
+ Assert.False(await user.VerifyPassword(defaultPassword));
Assert.False(await user.VerifyPassword("Test456"));
}
+ [Fact]
+ public async Task AccessTokenGrantsAccess()
+ {
+
+ UserManager manager = new();
+ Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS));
+
+ string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle;
+ Assert.NotNull(loginHandle);
+ Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user));
+
+ UserAccessToken token = await user.CreateAccessToken();
+
+ Assert.True(manager.TryUseAccessToken(token.TokenString, out User? outUser));
+
+ Assert.Equal(user, outUser);
+
+ }
+
+ [Fact]
+ public async Task LoginHandleChanges()
+ {
+
+ UserManager manager = new();
+ Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS));
+
+ string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle;
+ Assert.NotNull(loginHandle);
+ Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user));
+
+ string newHandle = "new-handle";
+
+ Assert.True(await user.TrySetLoginHandle(newHandle));
+
+ Assert.False(manager.TryGetUserByLoginHandle(loginHandle, out User? oldHandleUser));
+ Assert.Null(oldHandleUser);
+
+ Assert.True(manager.TryGetUserByLoginHandle(newHandle, out User? newHandleUser));
+ Assert.NotNull(newHandleUser);
+
+ }
+
}
diff --git a/PMDDesktop.Server.Users/AccessToken.cs b/PMDDesktop.Server.Users/AccessToken.cs
deleted file mode 100644
index 4874c99..0000000
--- a/PMDDesktop.Server.Users/AccessToken.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using System.Security.Cryptography;
-
-namespace PMDDesktop.Server.Users;
-
-public sealed class AccessToken
-{
-
- public static readonly TimeSpan TIME_UNTIL_EXPIRE = new(8, 0, 0, 0); // 8 days.
- public static readonly TimeSpan REFRESH_THRESHOLD = new(23, 0, 0); // Ideally if you log in once a week, you'll stay logged in.
-
- public static readonly int RANDOM_BYTE_COUNT = 32;
-
- internal AccessToken(string token, User forUser, DateTime expires)
- {
-
- TokenString = token;
- User = new(forUser);
- ExpiryDate = expires;
-
- }
-
- internal static AccessToken CreateNewToken(User forUser)
- {
-
- string tokenString = Convert.ToBase64String(RandomNumberGenerator.GetBytes(RANDOM_BYTE_COUNT));
-
- return new(tokenString, forUser, DateTime.Now + TIME_UNTIL_EXPIRE);
-
- }
-
- public DateTime ExpiryDate {get; private set;}
-
- public string TokenString {get; private set;}
-
- public UserReference User {get; internal set;}
-
- public void RefreshToken()
- {
-
- ExpiryDate = DateTime.UtcNow + TIME_UNTIL_EXPIRE;
-
- }
-
- public bool IsTokenValid()
- {
-
- DateTime now = DateTime.UtcNow;
-
- return now < ExpiryDate;
-
- }
-
-}
\ No newline at end of file
diff --git a/PMDDesktop.Server.Users/ISaveDataIndexable.cs b/PMDDesktop.Server.Users/IUserIndexable.cs
similarity index 72%
rename from PMDDesktop.Server.Users/ISaveDataIndexable.cs
rename to PMDDesktop.Server.Users/IUserIndexable.cs
index ab2f414..1a8b6fb 100644
--- a/PMDDesktop.Server.Users/ISaveDataIndexable.cs
+++ b/PMDDesktop.Server.Users/IUserIndexable.cs
@@ -8,10 +8,8 @@ namespace PMDDesktop.Server.Users;
public interface IUserIndexable
{
-
- User GetUser(Guid GUID);
+ User? GetUser(Guid GUID);
-
- bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User user);
+ bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user);
}
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index 4580fb9..d806fba 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -1,12 +1,21 @@
-using System.Text.Json.Serialization;
-using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity;
+using System.Text.Json.Serialization;
namespace PMDDesktop.Server.Users;
public sealed class User
{
- static PasswordHasher PASSWORD_HASHER = new();
+ private static PasswordHasher PASSWORD_HASHER = new();
+
+ ///
+ /// Limit of s assigned to one .
+ ///
+ ///
+ /// This is supposed to help combat a senario where a user creates millions of access tokens to intentionally fill up a hard drive or system memory.
+ /// Downside is that it prevents an honest user from having a large number of legitimate connections.
+ ///
+ public static readonly int ACCESS_TOKEN_LIMIT = 20;
[JsonConstructor]
internal User()
@@ -18,7 +27,7 @@ internal User()
/// A string representing a 's name.
///
[JsonInclude]
- public string Name {get; internal set;} = string.Empty;
+ public string Name { get; internal set; } = string.Empty;
///
/// A string to use for logging in. Should only ever contain [a-z], [0-9] and (-)
@@ -26,21 +35,29 @@ internal User()
/// Should be unique.
///
[JsonInclude]
- public string LoginHandle {get; internal set;} = string.Empty;
+ public string? LoginHandle { get; private set; }
///
/// Hashed password used to log in.
/// In the event that it is null, access to this account cannot be granted via password.
///
[JsonInclude]
- internal string? HashedPassword {get; set;}
+ private string? HashedPassword { get; set; }
///
/// A unique for each . Stays consistant even when the 's handle changes.
/// This should be used for long-term references to a specific .
///
[JsonIgnore]
- public Guid GUID {get; internal set;} = Guid.NewGuid();
+ public Guid GUID { get; internal set; } = Guid.NewGuid();
+
+ ///
+ ///
+ ///
+ [JsonIgnore]
+ public UserManager? Manager {get; private set;}
+
+ private List accessTokens = [];
///
/// Salt and hash the user's password, then store it in HashedPassword.
@@ -54,6 +71,58 @@ public async Task SetPassword(string plainText)
}
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task TrySetLoginHandle(string? newHandle)
+ {
+
+ // Handle is not the same.
+ if (newHandle == LoginHandle)
+ return false;
+
+ // Handle is free.
+ if (newHandle is not null && Manager is not null && Manager.loginHandles.ContainsKey(newHandle))
+ return false;
+
+ DetachLoginHandle();
+
+ LoginHandle = newHandle;
+
+ AttachLoginHandle();
+
+ return true;
+
+ }
+
+ private void AttachLoginHandle()
+ {
+
+ if (LoginHandle is null)
+ return;
+
+ Manager?.loginHandles.Add(LoginHandle, this);
+
+ }
+
+ private void DetachLoginHandle()
+ {
+
+ if (LoginHandle is null)
+ return;
+
+ Manager?.loginHandles.Remove(LoginHandle);
+
+ }
+
+ ///
+ /// Check that hashes to the same password stored in the user.
+ /// May also rehash the password if evidence is shown that it needs a rehash.
+ ///
+ /// The password input by the user.
+ /// True if the password matched.
public async Task VerifyPassword(string plainText)
{
@@ -73,10 +142,15 @@ public async Task VerifyPassword(string plainText)
}
- return result == PasswordVerificationResult.Success || result == PasswordVerificationResult.SuccessRehashNeeded;
+ return result is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded;
}
+ ///
+ /// Return the filesystem folder that should contain all of this user's data.
+ ///
+ /// A path pointing to the user's folder.
+ /// This path is created using the user's GUID.
internal string GetUserFolder()
{
@@ -84,7 +158,11 @@ internal string GetUserFolder()
}
- public string GetUserJSON()
+ ///
+ /// Gets the primary JSON that contains this user's data, located inside the user folder.
+ ///
+ /// A path pointing to where the user's json data is/should be stored.
+ public string GetUserJSONPath()
{
return Path.Combine(GetUserFolder(), "user.json");
@@ -94,7 +172,13 @@ public string GetUserJSON()
public async Task WriteNewData()
{
-
+ if (Manager is null) // ????? This state doesn't make any sense.
+ throw new InvalidOperationException($"{this} shouldn't be writing its data without being attached to a {nameof(Manager)}.");
+
+ if (!Manager.WritingEnabled)
+ return;
+
+ throw new NotImplementedException();
}
@@ -117,4 +201,93 @@ public override int GetHashCode()
return HashCode.Combine(GUID);
}
+ public async Task CreateAccessToken()
+ {
+
+ while (accessTokens.Count >= ACCESS_TOKEN_LIMIT)
+ await TryRevokeOldestAccessToken();
+
+ UserAccessToken token = UserAccessToken.CreateNewToken(this);
+
+ accessTokens.Add(token);
+
+ AttachAccessToken(token);
+
+ return token;
+
+ }
+
+ private void AttachAccessToken(UserAccessToken token)
+ {
+
+ // if (accessTokens.Any(existing => existing.TokenString == token.TokenString))
+ // throw new Exception($"The token: {token.TokenString} is already in {this}'s {nameof(accessTokens)}.");
+
+ Manager?.accessTokens.Add(token.TokenString, token);
+
+ }
+
+ public async Task RevokeAccessToken(UserAccessToken token)
+ {
+
+ accessTokens.Remove(token);
+
+ DetachAccessToken(token);
+
+ }
+
+ private void DetachAccessToken(UserAccessToken token)
+ {
+
+ Manager?.accessTokens.Remove(token.TokenString);
+
+ }
+
+ private async Task TryRevokeOldestAccessToken()
+ {
+
+ UserAccessToken? oldest = null;
+
+ foreach (UserAccessToken token in accessTokens)
+ {
+
+ oldest ??= token;
+
+ if (oldest.ExpiryDate > token.ExpiryDate)
+ oldest = token;
+
+ }
+
+ if (oldest is null)
+ return false;
+
+ await RevokeAccessToken(oldest);
+ return true;
+
+ }
+
+ internal void AttachToManager(UserManager manager)
+ {
+
+ Manager = manager;
+
+ AttachLoginHandle();
+
+ foreach (UserAccessToken token in accessTokens)
+ AttachAccessToken(token);
+
+ }
+
+ internal void DetachFromManager()
+ {
+
+ DetachLoginHandle();
+
+ foreach (UserAccessToken token in accessTokens)
+ DetachAccessToken(token);
+
+ Manager = null;
+
+ }
+
}
diff --git a/PMDDesktop.Server.Users/UserAccessToken.cs b/PMDDesktop.Server.Users/UserAccessToken.cs
new file mode 100644
index 0000000..ff4f71f
--- /dev/null
+++ b/PMDDesktop.Server.Users/UserAccessToken.cs
@@ -0,0 +1,76 @@
+using System.Security.Cryptography;
+
+namespace PMDDesktop.Server.Users;
+
+public sealed class UserAccessToken
+{
+
+ public static readonly TimeSpan TIME_UNTIL_EXPIRE = new(8, 0, 0, 0); // 8 days.
+ public static readonly TimeSpan REFRESH_THRESHOLD = new(7, 0, 0, 0); // 7 Days. // Ideally if you log in once a week, you'll stay logged in.
+
+ public static readonly int RANDOM_CHAR_COUNT = 64;
+
+ public static readonly char[] RANDOM_CHARS = [
+ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
+ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
+ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
+ '-', '_'
+ ];
+
+ internal UserAccessToken(string token, User forUser, DateTime expires)
+ {
+
+ TokenString = token;
+ User = forUser;
+ ExpiryDate = expires;
+
+ }
+
+ internal static UserAccessToken CreateNewToken(User forUser)
+ {
+
+ string tokenString = RandomNumberGenerator.GetString(RANDOM_CHARS, RANDOM_CHAR_COUNT);
+
+ return new(tokenString, forUser, DateTime.Now + TIME_UNTIL_EXPIRE);
+
+ }
+
+ public DateTime ExpiryDate { get; private set; }
+
+ public string TokenString { get; private set; }
+
+ public User User { get; internal set; }
+
+ public bool NeedsRefresh()
+ {
+
+ return DateTime.UtcNow < ExpiryDate - REFRESH_THRESHOLD;
+
+ }
+
+ public async Task RefreshToken()
+ {
+
+ ExpiryDate = DateTime.UtcNow + TIME_UNTIL_EXPIRE;
+
+ await User.WriteNewData();
+
+ }
+
+ public bool IsTokenValid()
+ {
+
+ DateTime now = DateTime.UtcNow;
+
+ return now < ExpiryDate;
+
+ }
+
+ public async Task RevokeToken()
+ {
+
+ await User.RevokeAccessToken(this);
+
+ }
+
+}
diff --git a/PMDDesktop.Server.Users/UserCreationOptions.cs b/PMDDesktop.Server.Users/UserCreationOptions.cs
new file mode 100644
index 0000000..05b4968
--- /dev/null
+++ b/PMDDesktop.Server.Users/UserCreationOptions.cs
@@ -0,0 +1,10 @@
+namespace PMDDesktop.Server.Users;
+
+public sealed record class UserCreationOptions
+{
+
+ public required string DisplayName {get; set;}
+ public string? PlainTextPassword {get; set;}
+ public string? LoginHandle {get; set;}
+
+}
diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs
index 2e50ea4..f3ba771 100644
--- a/PMDDesktop.Server.Users/UserManager.cs
+++ b/PMDDesktop.Server.Users/UserManager.cs
@@ -1,12 +1,132 @@
+using System.Collections;
+using System.Diagnostics.CodeAnalysis;
+
namespace PMDDesktop.Server.Users;
-public sealed class UserManager()
+public sealed class UserManager() : IEnumerable, IUserIndexable
{
+ ///
+ /// Does this actually write to files?
+ /// If this is false, file writes to the filesystem/disk will be skipped.
+ ///
+ ///
+ /// This is property as "false" is very useful in unit testing. Should probably be "true" during runtime.
+ ///
+ public bool WritingEnabled { get; private set; } = false;
+
+ ///
+ /// This is a dictionary with all the s being managed by this .
+ ///
+ ///
+ /// Entries are created and removed by the owning .
+ ///
private Dictionary users = [];
- private Dictionary tokens = [];
+ ///
+ /// This is a dictionary with all the s mapped to their .
+ ///
+ ///
+ /// This dictionary's entries are not managed by the , but instead by the s inside . Do not let write to this dictionary.
+ ///
+ internal Dictionary loginHandles = [];
+
+ ///
+ /// This is a dictionary with all the s mapped to their .
+ ///
+ ///
+ /// This dictionary's entries are not managed by the , but instead by the s inside . Do not let write to this dictionary.
+ ///
+ internal Dictionary accessTokens = [];
+
+ public IEnumerator GetEnumerator()
+ {
+ return users.Values.GetEnumerator();
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return GetEnumerator();
+ }
+
+ ///
+ /// Try to get a using the string of an access token. Never use this function for any purpose other than authorization.
+ /// This function respects token expiration, and may clear tokens that have expired. It may also refresh a token's expiration if a certain amount of time has passed.
+ /// If this is , this function may write to the filesystem/disk to update token information.
+ ///
+ /// The token string corresponding to a , but not an error if it doesn't.
+ ///
+ public bool TryUseAccessToken(string tokenString, [NotNullWhen(true)] out User? user)
+ {
+
+ return TryUseAccessToken(tokenString, out user, out _);
+
+ }
+
+ ///
+ /// Try to get a using the string of an access token. Never use this function for any purpose other than authorization.
+ /// This function respects token expiration, and may clear tokens that have expired. It may also refresh a token's expiration if a certain amount of time has passed.
+ /// If this is , this function may write to the filesystem/disk to update token information.
+ ///
+ /// The token string corresponding to a , but not an error if it doesn't.
+ /// True if access should be allowed, otherwise false.
+ /// Sometimes, and may not be null, even if false was returned. In this case, do not give access, but you may inspect the results.
+ public bool TryUseAccessToken(string tokenString, [NotNullWhen(true)] out User? user, [NotNullWhen(true)] out UserAccessToken? token)
+ {
+
+ accessTokens.TryGetValue(tokenString, out token);
+ user = token?.User;
+
+ if (token is null || user is null)
+ return false;
+
+ if (!token.IsTokenValid())
+ return false;
+
+ return true;
+
+ }
+
+ public User? GetUser(Guid GUID)
+ {
+
+ TryGetUser(GUID, out User? user);
+
+ return user;
+
+ }
+
+ public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user)
+ {
+
+ return users.TryGetValue(GUID, out user);
+
+ }
+
+ public bool TryGetUserByLoginHandle(string handle, [NotNullWhen(true)] out User? user)
+ {
+
+ return loginHandles.TryGetValue(handle, out user);
+
+ }
+
+ public async Task TryCreateUser(UserCreationOptions options)
+ {
+
+ User user = new()
+ {
+ Name = options.DisplayName
+ };
+
+ if (options.LoginHandle is not null)
+ if (!await user.TrySetLoginHandle(options.LoginHandle))
+ return false;
+ if (options.PlainTextPassword is not null)
+ await user.SetPassword(options.PlainTextPassword);
+
+ user.AttachToManager(this);
+ return true;
-
+ }
-}
\ No newline at end of file
+}
diff --git a/PMDDesktop.Server.Users/UserReference.cs b/PMDDesktop.Server.Users/UserReference.cs
index d338b14..51b9ad7 100644
--- a/PMDDesktop.Server.Users/UserReference.cs
+++ b/PMDDesktop.Server.Users/UserReference.cs
@@ -1,4 +1,4 @@
-using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace PMDDesktop.Server.Users;
@@ -9,7 +9,7 @@ public sealed class UserReference
public Guid GUID { get; private set; }
-
+
public UserReference(User initalValue)
{
@@ -17,7 +17,7 @@ public UserReference(User initalValue)
}
-
+
internal UserReference(Guid directGuid)
{
@@ -25,16 +25,16 @@ internal UserReference(Guid directGuid)
}
-
- public User GetUser(IUserIndexable indexable)
+
+ public User? GetUser(IUserIndexable indexable)
{
return indexable.GetUser(GUID);
}
-
- public bool TryGetUser(IUserIndexable indexable, [NotNullWhen(true)] out User user)
+
+ public bool TryGetUser(IUserIndexable indexable, [NotNullWhen(true)] out User? user)
{
return indexable.TryGetUser(GUID, out user);
diff --git a/PMDDesktop.Server.Users/SaveDataReferenceConverter.cs b/PMDDesktop.Server.Users/UserReferenceConverter.cs
similarity index 100%
rename from PMDDesktop.Server.Users/SaveDataReferenceConverter.cs
rename to PMDDesktop.Server.Users/UserReferenceConverter.cs
diff --git a/PMDDesktop.Server/Game/GameState.cs b/PMDDesktop.Server/Game/GameState.cs
index 9b7d8fb..8dc1af0 100644
--- a/PMDDesktop.Server/Game/GameState.cs
+++ b/PMDDesktop.Server/Game/GameState.cs
@@ -1,5 +1,6 @@
using PMDDesktop.Server.Assets;
using PMDDesktop.Server.Saving;
+using PMDDesktop.Server.Users;
using System.Diagnostics.CodeAnalysis;
namespace PMDDesktop.Server.Game;
@@ -50,5 +51,6 @@ public bool TryGetSave(Guid GUID, [NotNullWhen(true)] out T? data) where T :
public SaveDataManager Saves { get; } = new();
public AssetManager Assets { get; } = new();
+ public UserManager Users { get; } = new();
}
diff --git a/PMDDesktop.Server/PMDDesktop.Server.csproj b/PMDDesktop.Server/PMDDesktop.Server.csproj
index af927f5..47aaf46 100644
--- a/PMDDesktop.Server/PMDDesktop.Server.csproj
+++ b/PMDDesktop.Server/PMDDesktop.Server.csproj
@@ -19,6 +19,7 @@
+
diff --git a/PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs
new file mode 100644
index 0000000..e706195
--- /dev/null
+++ b/PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs
@@ -0,0 +1,26 @@
+using PMDDesktop.Requests.Auth;
+using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
+
+namespace PMDDesktop.Server.RequestHandlers.Auth;
+
+public class LoginRequestHandler : JsonRequestHandler
+{
+
+ protected override async Task CreateResponse(LoginRequest request, GameServer game)
+ {
+
+ if (!game.State.Users.TryGetUserByLoginHandle(request.Handle, out User? user))
+ throw new UserRequestException($"Invalid username or password.");
+
+ if (!await user.VerifyPassword(request.Password))
+ throw new UserRequestException($"Invalid username or password."); // Throw the same thing to be more "secure" I guess.
+
+ return new()
+ {
+ AuthorizationToken = (await user.CreateAccessToken()).TokenString
+ };
+
+ }
+
+}
diff --git a/PMDDesktop/Requests/Auth/LoginRequest.cs b/PMDDesktop/Requests/Auth/LoginRequest.cs
new file mode 100644
index 0000000..28611e4
--- /dev/null
+++ b/PMDDesktop/Requests/Auth/LoginRequest.cs
@@ -0,0 +1,10 @@
+namespace PMDDesktop.Requests.Auth;
+
+[ServerRequest("/api/auth/login")]
+public sealed class LoginRequest : ServerRequest
+{
+
+ public required string Handle { get; set; }
+ public required string Password { get; set; }
+
+}
diff --git a/PMDDesktop/Requests/Auth/LoginResponse.cs b/PMDDesktop/Requests/Auth/LoginResponse.cs
new file mode 100644
index 0000000..81e8c17
--- /dev/null
+++ b/PMDDesktop/Requests/Auth/LoginResponse.cs
@@ -0,0 +1,8 @@
+namespace PMDDesktop.Requests.Auth;
+
+public sealed class LoginResponse : ServerResponse
+{
+
+ public required string AuthorizationToken { get; set; }
+
+}
From 753f26fe1ff2bf7e3282ac549c6a5462cef3758f Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Sun, 20 Sep 2026 23:04:22 -0500
Subject: [PATCH 04/55] Fix GUID methods.
---
PMDDesktop.Server.Users.Tests/UserTests.cs | 21 +++++++++++++++++++++
PMDDesktop.Server.Users/User.cs | 10 ++++++++++
PMDDesktop.Server.Users/UserManager.cs | 14 +++++++-------
3 files changed, 38 insertions(+), 7 deletions(-)
diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs
index 6e8751a..37db7d2 100644
--- a/PMDDesktop.Server.Users.Tests/UserTests.cs
+++ b/PMDDesktop.Server.Users.Tests/UserTests.cs
@@ -94,4 +94,25 @@ public async Task LoginHandleChanges()
}
+ [Fact]
+ public async Task GetViaGUID()
+ {
+
+ UserManager manager = new();
+ Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS));
+
+ string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle;
+ Assert.NotNull(loginHandle);
+ Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user));
+
+ Guid guid = user.GUID;
+
+ Assert.True(manager.TryGetUser(guid, out User? guidUser));
+ Assert.NotNull(guidUser);
+
+ Assert.False(manager.TryGetUser(Guid.Empty, out User? notGuidUser));
+ Assert.Null(notGuidUser);
+
+ }
+
}
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index d806fba..48fa336 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -270,6 +270,8 @@ internal void AttachToManager(UserManager manager)
{
Manager = manager;
+
+ Manager.guids.Add(GUID, this);
AttachLoginHandle();
@@ -281,6 +283,14 @@ internal void AttachToManager(UserManager manager)
internal void DetachFromManager()
{
+ if (Manager==null)
+ throw new NullReferenceException($"{nameof(Manager)} is null. Cannot detach from nothing. Does this call to {nameof(DetachFromManager)} need to be skipped?");
+
+ if (Manager.guids[GUID] != this)
+ throw new InvalidOperationException($"{this} was not inside {Manager.guids} with key {GUID}.");
+
+ Manager.guids.Remove(GUID);
+
DetachLoginHandle();
foreach (UserAccessToken token in accessTokens)
diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs
index f3ba771..b3979e6 100644
--- a/PMDDesktop.Server.Users/UserManager.cs
+++ b/PMDDesktop.Server.Users/UserManager.cs
@@ -16,18 +16,18 @@ public sealed class UserManager() : IEnumerable, IUserIndexable
public bool WritingEnabled { get; private set; } = false;
///
- /// This is a dictionary with all the s being managed by this .
+ /// This is a dictionary with all the s mapped to their .
///
///
- /// Entries are created and removed by the owning .
+ /// This dictionary's entries are not managed by the , but instead by the s as they are attached/detached. Do not let write to this dictionary.
///
- private Dictionary users = [];
+ internal Dictionary guids = [];
///
/// This is a dictionary with all the s mapped to their .
///
///
- /// This dictionary's entries are not managed by the , but instead by the s inside . Do not let write to this dictionary.
+ /// This dictionary's entries are not managed by the , but instead by the s inside . Do not let write to this dictionary.
///
internal Dictionary loginHandles = [];
@@ -35,13 +35,13 @@ public sealed class UserManager() : IEnumerable, IUserIndexable
/// This is a dictionary with all the s mapped to their .
///
///
- /// This dictionary's entries are not managed by the , but instead by the s inside . Do not let write to this dictionary.
+ /// This dictionary's entries are not managed by the , but instead by the s inside . Do not let write to this dictionary.
///
internal Dictionary accessTokens = [];
public IEnumerator GetEnumerator()
{
- return users.Values.GetEnumerator();
+ return guids.Values.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
@@ -99,7 +99,7 @@ public bool TryUseAccessToken(string tokenString, [NotNullWhen(true)] out User?
public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user)
{
- return users.TryGetValue(GUID, out user);
+ return guids.TryGetValue(GUID, out user);
}
From 47975427f2f8eba6c991d0911f7b2106f38802be Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Mon, 21 Sep 2026 13:11:04 -0500
Subject: [PATCH 05/55] edit TryCreateUser() to output Task
---
PMDDesktop.Server.Users.Tests/UserTests.cs | 35 ++++++++--------------
PMDDesktop.Server.Users/UserManager.cs | 8 ++---
2 files changed, 16 insertions(+), 27 deletions(-)
diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs
index 37db7d2..af52c85 100644
--- a/PMDDesktop.Server.Users.Tests/UserTests.cs
+++ b/PMDDesktop.Server.Users.Tests/UserTests.cs
@@ -15,11 +15,8 @@ public async Task PasswordVerification()
{
UserManager manager = new();
- Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS));
-
- string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle;
- Assert.NotNull(loginHandle);
- Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user));
+ User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
+ Assert.NotNull(user);
string? defaultPassword = DEFAULT_USER_OPTIONS.PlainTextPassword;
Assert.NotNull(defaultPassword);
@@ -35,14 +32,11 @@ public async Task NullPasswordVerification()
{
UserManager manager = new();
- Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS with
+ User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS with
{
PlainTextPassword = null
- }));
-
- string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle;
- Assert.NotNull(loginHandle);
- Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user));
+ });
+ Assert.NotNull(user);
string? defaultPassword = DEFAULT_USER_OPTIONS.PlainTextPassword;
Assert.NotNull(defaultPassword);
@@ -57,11 +51,8 @@ public async Task AccessTokenGrantsAccess()
{
UserManager manager = new();
- Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS));
-
- string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle;
- Assert.NotNull(loginHandle);
- Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user));
+ User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
+ Assert.NotNull(user);
UserAccessToken token = await user.CreateAccessToken();
@@ -76,11 +67,12 @@ public async Task LoginHandleChanges()
{
UserManager manager = new();
- Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS));
+ User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
+ Assert.NotNull(user);
string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle;
Assert.NotNull(loginHandle);
- Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user));
+ Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out _));
string newHandle = "new-handle";
@@ -99,11 +91,8 @@ public async Task GetViaGUID()
{
UserManager manager = new();
- Assert.True(await manager.TryCreateUser(DEFAULT_USER_OPTIONS));
-
- string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle;
- Assert.NotNull(loginHandle);
- Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out User? user));
+ User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
+ Assert.NotNull(user);
Guid guid = user.GUID;
diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs
index b3979e6..2294b04 100644
--- a/PMDDesktop.Server.Users/UserManager.cs
+++ b/PMDDesktop.Server.Users/UserManager.cs
@@ -110,22 +110,22 @@ public bool TryGetUserByLoginHandle(string handle, [NotNullWhen(true)] out User?
}
- public async Task TryCreateUser(UserCreationOptions options)
+ public async Task TryCreateUser(UserCreationOptions options)
{
User user = new()
{
Name = options.DisplayName
};
-
+
if (options.LoginHandle is not null)
if (!await user.TrySetLoginHandle(options.LoginHandle))
- return false;
+ return null;
if (options.PlainTextPassword is not null)
await user.SetPassword(options.PlainTextPassword);
user.AttachToManager(this);
- return true;
+ return user;
}
From 558c6ac76c45a1b957a0f6685242ad8f92d4c968 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Mon, 21 Sep 2026 13:43:22 -0500
Subject: [PATCH 06/55] Add token limit tests.
---
PMDDesktop.Server.Users.Tests/UserTests.cs | 50 ++++++++++++++++++++--
1 file changed, 46 insertions(+), 4 deletions(-)
diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs
index af52c85..5f717fb 100644
--- a/PMDDesktop.Server.Users.Tests/UserTests.cs
+++ b/PMDDesktop.Server.Users.Tests/UserTests.cs
@@ -3,7 +3,7 @@
public class UserTests
{
- public static readonly UserCreationOptions DEFAULT_USER_OPTIONS = new()
+ public static readonly UserCreationOptions DEFAULT_USER_OPTIONS = new()
{
DisplayName = "Test User",
LoginHandle = "test",
@@ -49,7 +49,7 @@ public async Task NullPasswordVerification()
[Fact]
public async Task AccessTokenGrantsAccess()
{
-
+
UserManager manager = new();
User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
Assert.NotNull(user);
@@ -65,7 +65,7 @@ public async Task AccessTokenGrantsAccess()
[Fact]
public async Task LoginHandleChanges()
{
-
+
UserManager manager = new();
User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
Assert.NotNull(user);
@@ -89,7 +89,7 @@ public async Task LoginHandleChanges()
[Fact]
public async Task GetViaGUID()
{
-
+
UserManager manager = new();
User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
Assert.NotNull(user);
@@ -104,4 +104,46 @@ public async Task GetViaGUID()
}
+ [Fact]
+ public async Task IsTokenLimitEnforced()
+ {
+
+ UserManager manager = new();
+ User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
+ Assert.NotNull(user);
+
+ for (int i = 0; i < User.ACCESS_TOKEN_LIMIT + 100; ++i)
+ {
+ await user.CreateAccessToken();
+ }
+
+ Assert.Equal(manager.accessTokens.Count, User.ACCESS_TOKEN_LIMIT);
+
+ }
+
+ [Fact]
+ public async Task AreOldestTokensRemovedOnLimit()
+ {
+
+ UserManager manager = new();
+ User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
+ Assert.NotNull(user);
+
+ for (int i = 0; i < User.ACCESS_TOKEN_LIMIT; ++i)
+ await user.CreateAccessToken();
+
+ Assert.Equal(manager.accessTokens.Count, User.ACCESS_TOKEN_LIMIT);
+
+ List seenTokens = [.. manager.accessTokens.Values];
+
+ for (int i = 0; i < User.ACCESS_TOKEN_LIMIT; ++i)
+ await user.CreateAccessToken();
+
+ Assert.Equal(manager.accessTokens.Count, User.ACCESS_TOKEN_LIMIT);
+
+ seenTokens.AddRange(manager.accessTokens.Values);
+ Assert.Distinct(seenTokens);
+
+ }
+
}
From be0615d7e44990b50602bdf03356a7181677eeba Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Mon, 21 Sep 2026 14:24:49 -0500
Subject: [PATCH 07/55] Add User removal
---
PMDDesktop.Server.Users.Tests/UserTests.cs | 29 +++++++
PMDDesktop.Server.Users/User.cs | 92 +++++++++++++++++++---
2 files changed, 110 insertions(+), 11 deletions(-)
diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs
index 5f717fb..b865828 100644
--- a/PMDDesktop.Server.Users.Tests/UserTests.cs
+++ b/PMDDesktop.Server.Users.Tests/UserTests.cs
@@ -146,4 +146,33 @@ public async Task AreOldestTokensRemovedOnLimit()
}
+ [Fact]
+ public async Task DeleteUserTest()
+ {
+
+ UserManager manager = new();
+ User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
+ Assert.NotNull(user);
+ await user.CreateAccessToken();
+
+ Assert.True(manager.TryGetUser(user.GUID, out _));
+
+ Assert.NotEmpty(manager.accessTokens);
+ Assert.NotEmpty(manager.guids);
+ Assert.NotEmpty(manager.loginHandles);
+
+ Assert.True(user.IsAlive());
+
+ Assert.True(await user.TryRemoveUser());
+
+ Assert.False(manager.TryGetUser(user.GUID, out _));
+
+ Assert.Empty(manager.accessTokens);
+ Assert.Empty(manager.guids);
+ Assert.Empty(manager.loginHandles);
+
+ Assert.False(user.IsAlive());
+
+ }
+
}
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index 48fa336..54710d9 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Identity;
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace PMDDesktop.Server.Users;
@@ -8,6 +9,11 @@ public sealed class User
private static PasswordHasher PASSWORD_HASHER = new();
+ ///
+ /// A list
+ ///
+ private static string[] DELETE_USER_FOLDER_ITEMS = ["user.json", "picture.jpg", "picture.png"];
+
///
/// Limit of s assigned to one .
///
@@ -52,11 +58,13 @@ internal User()
public Guid GUID { get; internal set; } = Guid.NewGuid();
///
- ///
+ ///
///
[JsonIgnore]
public UserManager? Manager {get; private set;}
+ public bool? WritingEnabled {get; private set;}
+
private List accessTokens = [];
///
@@ -72,13 +80,13 @@ public async Task SetPassword(string plainText)
}
///
- ///
+ ///
///
///
///
public async Task TrySetLoginHandle(string? newHandle)
{
-
+
// Handle is not the same.
if (newHandle == LoginHandle)
return false;
@@ -109,7 +117,7 @@ private void AttachLoginHandle()
private void DetachLoginHandle()
{
-
+
if (LoginHandle is null)
return;
@@ -169,19 +177,57 @@ public string GetUserJSONPath()
}
- public async Task WriteNewData()
+ [ExcludeFromCodeCoverage]
+ internal async Task WriteNewData()
{
if (Manager is null) // ????? This state doesn't make any sense.
throw new InvalidOperationException($"{this} shouldn't be writing its data without being attached to a {nameof(Manager)}.");
- if (!Manager.WritingEnabled)
+ if (WritingEnabled is null)
+ throw new InvalidOperationException($"{this} shouldn't be deleting its data while {WritingEnabled} is null!");
+
+ if (WritingEnabled != true)
return;
throw new NotImplementedException();
}
+ ///
+ /// Deletes the user folder and all *explicitly written* data inside it.
+ ///
+ ///
+ ///
+ /// This function does not recursive delete. Instead it has a list of items to delete. This approach is taken to minimize data loss in the event that something goes wrong.
+ ///
+ [ExcludeFromCodeCoverage]
+ private async Task DeleteUserFolder()
+ {
+
+ if (WritingEnabled is null)
+ throw new InvalidOperationException($"{this} shouldn't be deleting its data while {WritingEnabled} is null!");
+
+ if (WritingEnabled != true)
+ return;
+
+ foreach (string item in DELETE_USER_FOLDER_ITEMS)
+ {
+
+ string path = Path.Join(GetUserFolder(), item);
+
+ if (File.Exists(path))
+ File.Delete(path);
+
+ }
+
+ if (Directory.GetFileSystemEntries(GetUserFolder()).Length == 0)
+ Directory.Delete(GetUserFolder(), false);
+ else
+ Console.WriteLine($"CANNOT DELETE ALL OF {this}'s user folder!!!");
+
+ }
+
public override bool Equals(object? obj)
{
@@ -219,7 +265,7 @@ public async Task CreateAccessToken()
private void AttachAccessToken(UserAccessToken token)
{
-
+
// if (accessTokens.Any(existing => existing.TokenString == token.TokenString))
// throw new Exception($"The token: {token.TokenString} is already in {this}'s {nameof(accessTokens)}.");
@@ -250,7 +296,7 @@ private async Task TryRevokeOldestAccessToken()
foreach (UserAccessToken token in accessTokens)
{
-
+
oldest ??= token;
if (oldest.ExpiryDate > token.ExpiryDate)
@@ -268,11 +314,12 @@ private async Task TryRevokeOldestAccessToken()
internal void AttachToManager(UserManager manager)
{
-
+
Manager = manager;
+ WritingEnabled = manager.WritingEnabled;
Manager.guids.Add(GUID, this);
-
+
AttachLoginHandle();
foreach (UserAccessToken token in accessTokens)
@@ -288,7 +335,7 @@ internal void DetachFromManager()
if (Manager.guids[GUID] != this)
throw new InvalidOperationException($"{this} was not inside {Manager.guids} with key {GUID}.");
-
+
Manager.guids.Remove(GUID);
DetachLoginHandle();
@@ -300,4 +347,27 @@ internal void DetachFromManager()
}
+ public bool IsAlive() => Manager is not null;
+
+ ///
+ /// Try to remove this from its .
+ /// If the assigned has , then this function will also attempt to delete the user's folder from the filesystem, so that it will not be restored in future sessions.
+ ///
+ ///
+ /// If is enabled, this function may still throw if an IO-related error occurs.
+ ///
+ public async Task TryRemoveUser()
+ {
+
+ if (Manager is null)
+ return false;
+
+ DetachFromManager();
+
+ await DeleteUserFolder();
+
+ return true;
+
+ }
+
}
From 55ecf969e1eb17db8d7b923d406cdc1673682c9a Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Mon, 21 Sep 2026 15:53:01 -0500
Subject: [PATCH 08/55] Add UserReferenceTests
---
.../PMDDesktop.Server.Users.Tests.csproj | 1 +
.../UserReferenceTests.cs | 58 +++++++++++++++++++
2 files changed, 59 insertions(+)
create mode 100644 PMDDesktop.Server.Users.Tests/UserReferenceTests.cs
diff --git a/PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj b/PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj
index c988a3a..a233e0d 100644
--- a/PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj
+++ b/PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj
@@ -16,6 +16,7 @@
+
diff --git a/PMDDesktop.Server.Users.Tests/UserReferenceTests.cs b/PMDDesktop.Server.Users.Tests/UserReferenceTests.cs
new file mode 100644
index 0000000..ac6c533
--- /dev/null
+++ b/PMDDesktop.Server.Users.Tests/UserReferenceTests.cs
@@ -0,0 +1,58 @@
+using System.Text.Json;
+using Microsoft.AspNetCore.Mvc.ApiExplorer;
+
+namespace PMDDesktop.Server.Users.Tests;
+
+public class UserReferenceTests
+{
+
+ public static readonly UserCreationOptions DEFAULT_USER_OPTIONS = new()
+ {
+ DisplayName = "Test User",
+ LoginHandle = "test",
+ PlainTextPassword = "Test123"
+ };
+
+ [Fact]
+ public async Task CanGetReference()
+ {
+
+ UserManager manager = new();
+ User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
+ Assert.NotNull(user);
+
+ UserReference reference = new(user);
+
+ reference.TryGetUser(manager, out User? referUser);
+
+ Assert.NotNull(referUser);
+ Assert.Equal(referUser, user);
+
+ }
+
+ [Fact]
+ public async Task SerializationIsSimple()
+ {
+
+ UserManager manager = new();
+ User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
+ Assert.NotNull(user);
+
+ UserReference preReference = new(user);
+
+ JsonElement json = JsonSerializer.SerializeToElement(preReference, AppInfo.JSON_OPTIONS);
+
+ Assert.Equal(JsonValueKind.String, json.ValueKind);
+
+ UserReference? postReference = JsonSerializer.Deserialize(json);
+
+ Assert.NotNull(postReference);
+
+ postReference.TryGetUser(manager, out User? referUser);
+
+ Assert.NotNull(referUser);
+ Assert.Equal(referUser, user);
+
+ }
+
+}
From 77e69d8dcb08e0ad53571bd1dc77ceb4ba4a3c7a Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Mon, 21 Sep 2026 16:03:12 -0500
Subject: [PATCH 09/55] remove random unused import???
---
PMDDesktop.Server.Users.Tests/UserReferenceTests.cs | 1 -
1 file changed, 1 deletion(-)
diff --git a/PMDDesktop.Server.Users.Tests/UserReferenceTests.cs b/PMDDesktop.Server.Users.Tests/UserReferenceTests.cs
index ac6c533..f534ddc 100644
--- a/PMDDesktop.Server.Users.Tests/UserReferenceTests.cs
+++ b/PMDDesktop.Server.Users.Tests/UserReferenceTests.cs
@@ -1,5 +1,4 @@
using System.Text.Json;
-using Microsoft.AspNetCore.Mvc.ApiExplorer;
namespace PMDDesktop.Server.Users.Tests;
From 66badd1aed2e48408ea2f9254054a32493f54142 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Mon, 21 Sep 2026 16:04:43 -0500
Subject: [PATCH 10/55] remove GetUser in IUserIndexable
---
PMDDesktop.Server.Users/IUserIndexable.cs | 2 --
PMDDesktop.Server.Users/UserManager.cs | 9 ---------
PMDDesktop.Server.Users/UserReference.cs | 8 --------
3 files changed, 19 deletions(-)
diff --git a/PMDDesktop.Server.Users/IUserIndexable.cs b/PMDDesktop.Server.Users/IUserIndexable.cs
index 1a8b6fb..78474b5 100644
--- a/PMDDesktop.Server.Users/IUserIndexable.cs
+++ b/PMDDesktop.Server.Users/IUserIndexable.cs
@@ -8,8 +8,6 @@ namespace PMDDesktop.Server.Users;
public interface IUserIndexable
{
- User? GetUser(Guid GUID);
-
bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user);
}
diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs
index 2294b04..d09097a 100644
--- a/PMDDesktop.Server.Users/UserManager.cs
+++ b/PMDDesktop.Server.Users/UserManager.cs
@@ -87,15 +87,6 @@ public bool TryUseAccessToken(string tokenString, [NotNullWhen(true)] out User?
}
- public User? GetUser(Guid GUID)
- {
-
- TryGetUser(GUID, out User? user);
-
- return user;
-
- }
-
public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user)
{
diff --git a/PMDDesktop.Server.Users/UserReference.cs b/PMDDesktop.Server.Users/UserReference.cs
index 51b9ad7..86011b9 100644
--- a/PMDDesktop.Server.Users/UserReference.cs
+++ b/PMDDesktop.Server.Users/UserReference.cs
@@ -26,14 +26,6 @@ internal UserReference(Guid directGuid)
}
- public User? GetUser(IUserIndexable indexable)
- {
-
- return indexable.GetUser(GUID);
-
- }
-
-
public bool TryGetUser(IUserIndexable indexable, [NotNullWhen(true)] out User? user)
{
From 1f686c01e13f7c2b997c91cf635f53cf0f093942 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Mon, 21 Sep 2026 16:05:56 -0500
Subject: [PATCH 11/55] Add IUserIndexable to GameServer and GameState
---
PMDDesktop.Server/Game/GameServer.cs | 7 ++++++-
PMDDesktop.Server/Game/GameState.cs | 7 ++++++-
2 files changed, 12 insertions(+), 2 deletions(-)
diff --git a/PMDDesktop.Server/Game/GameServer.cs b/PMDDesktop.Server/Game/GameServer.cs
index 0a755f7..5e29772 100644
--- a/PMDDesktop.Server/Game/GameServer.cs
+++ b/PMDDesktop.Server/Game/GameServer.cs
@@ -1,10 +1,11 @@
using PMDDesktop.Server.Assets;
using PMDDesktop.Server.Saving;
+using PMDDesktop.Server.Users;
using System.Diagnostics.CodeAnalysis;
namespace PMDDesktop.Server.Game;
-public class GameServer : IAssetIndexable, ISaveDataIndexable
+public class GameServer : IAssetIndexable, ISaveDataIndexable, IUserIndexable
{
public GameServer()
@@ -74,4 +75,8 @@ public bool TryGetSave(Guid GUID, [NotNullWhen(true)] out T? data) where T :
return State.TryGetSave(GUID, out data);
}
+ public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user)
+ {
+ return State.TryGetUser(GUID, out user);
+ }
}
diff --git a/PMDDesktop.Server/Game/GameState.cs b/PMDDesktop.Server/Game/GameState.cs
index 8dc1af0..d8c80b8 100644
--- a/PMDDesktop.Server/Game/GameState.cs
+++ b/PMDDesktop.Server/Game/GameState.cs
@@ -5,7 +5,7 @@
namespace PMDDesktop.Server.Game;
-public class GameState : IAssetIndexable, ISaveDataIndexable
+public class GameState : IAssetIndexable, ISaveDataIndexable, IUserIndexable
{
[ExcludeFromCodeCoverage]
@@ -49,6 +49,11 @@ public bool TryGetSave(Guid GUID, [NotNullWhen(true)] out T? data) where T :
return Saves.TryGetSave(GUID, out data);
}
+ public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user)
+ {
+ return Users.TryGetUser(GUID, out user);
+ }
+
public SaveDataManager Saves { get; } = new();
public AssetManager Assets { get; } = new();
public UserManager Users { get; } = new();
From a3068a9643aff321439488670189689eb645c77e Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Mon, 21 Sep 2026 16:56:51 -0500
Subject: [PATCH 12/55] read Authorization header on request
---
.../Auth/LoginRequestHandler.cs | 2 +-
.../RequestHandlers/IRequestHandler.cs | 20 ++++++++++++++++++-
.../RequestHandlers/JsonRequestHandler.cs | 10 +++++++---
.../ListSpeciesRequestHandler.cs | 3 ++-
.../ListTypesRequestHandler.cs | 3 ++-
5 files changed, 31 insertions(+), 7 deletions(-)
diff --git a/PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs
index e706195..0d3c6d4 100644
--- a/PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs
+++ b/PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs
@@ -7,7 +7,7 @@ namespace PMDDesktop.Server.RequestHandlers.Auth;
public class LoginRequestHandler : JsonRequestHandler
{
- protected override async Task CreateResponse(LoginRequest request, GameServer game)
+ protected override async Task CreateResponse(LoginRequest request, GameServer game, User? requestingUser)
{
if (!game.State.Users.TryGetUserByLoginHandle(request.Handle, out User? user))
diff --git a/PMDDesktop.Server/RequestHandlers/IRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/IRequestHandler.cs
index 5c92853..d715491 100644
--- a/PMDDesktop.Server/RequestHandlers/IRequestHandler.cs
+++ b/PMDDesktop.Server/RequestHandlers/IRequestHandler.cs
@@ -1,6 +1,8 @@
-using PMDDesktop.Exceptions;
+using System.Diagnostics.CodeAnalysis;
+using PMDDesktop.Exceptions;
using PMDDesktop.Requests;
using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
using PMDDesktop.Utils;
namespace PMDDesktop.Server.RequestHandlers;
@@ -44,6 +46,22 @@ static void AddApiPoints(GameServer server)
}
+ static bool TryGetUserFromContext(UserManager manager, HttpContext context, [NotNullWhen(true)] out User? user, [NotNullWhen(true)] out UserAccessToken? accessToken)
+ {
+
+ string? token = context.Request.Headers.Authorization;
+
+ if (token is null)
+ {
+ user = null;
+ accessToken = null;
+ return false;
+ }
+
+ return manager.TryUseAccessToken(token, out user, out accessToken);
+
+ }
+
///
/// Process the full request as needed.
///
diff --git a/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs
index 7ae343f..ee6cecf 100644
--- a/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs
+++ b/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs
@@ -1,5 +1,6 @@
using PMDDesktop.Requests;
using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
using PMDDesktop.Utils;
using System.Reflection;
using System.Text;
@@ -158,15 +159,18 @@ public async Task HandleRequest(HttpContext context, GameServer game)
}
+ if (IRequestHandler.TryGetUserFromContext(game.State.Users, context, out User? user, out UserAccessToken? token))
+ await token.RefreshToken();
+
try
{
context.Response.StatusCode = 200;
context.Response.ContentType = "application/json";
- await context.Response.WriteAsJsonAsync(await CreateResponse(deserialized, game), AppInfo.NETWORK_JSON_OPTIONS);
+ await context.Response.WriteAsJsonAsync(await CreateResponse(deserialized, game, user), AppInfo.NETWORK_JSON_OPTIONS);
}
- catch (UserRequestException e) // Intended for user-facing errors, like bad inputs,
+ catch (UserRequestException e) // Intended for user-facing errors, like bad inputs,
{
await WritePlainText(context, 400, e.Message);
@@ -200,6 +204,6 @@ private static async Task WritePlainText(HttpContext context, int code, string e
}
- protected abstract Task CreateResponse(TReq request, GameServer game);
+ protected abstract Task CreateResponse(TReq request, GameServer game, User? requestingUser);
}
diff --git a/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs
index 4e1cc57..70ce47a 100644
--- a/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs
+++ b/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs
@@ -1,13 +1,14 @@
using PMDDesktop.Requests;
using PMDDesktop.Server.Assets.Data;
using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
namespace PMDDesktop.Server.RequestHandlers;
public class ListSpeciesRequestHandler : JsonRequestHandler
{
- protected override async Task CreateResponse(ListSpeciesRequest request, GameServer game)
+ protected override async Task CreateResponse(ListSpeciesRequest request, GameServer game, User? requestingUser)
{
return new()
diff --git a/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs
index 6b43b34..56092e4 100644
--- a/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs
+++ b/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs
@@ -1,13 +1,14 @@
using PMDDesktop.Requests;
using PMDDesktop.Server.Assets.Data;
using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
namespace PMDDesktop.Server.RequestHandlers;
public class ListTypesRequestHandler : JsonRequestHandler
{
- protected override async Task CreateResponse(ListTypesRequest request, GameServer game)
+ protected override async Task CreateResponse(ListTypesRequest request, GameServer game, User? requestingUser)
{
return new()
From 754330817aa107112083eebf35c52b49c57bb6b0 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Mon, 21 Sep 2026 16:57:17 -0500
Subject: [PATCH 13/55] Add "auth/who-am-i" endpoint.
---
.../Auth/WhoAmIRequestHandler.cs | 23 +++++++++++++++++++
PMDDesktop/Requests/Auth/WhoAmIRequest.cs | 9 ++++++++
PMDDesktop/Requests/Auth/WhoAmIResponse.cs | 8 +++++++
3 files changed, 40 insertions(+)
create mode 100644 PMDDesktop.Server/RequestHandlers/Auth/WhoAmIRequestHandler.cs
create mode 100644 PMDDesktop/Requests/Auth/WhoAmIRequest.cs
create mode 100644 PMDDesktop/Requests/Auth/WhoAmIResponse.cs
diff --git a/PMDDesktop.Server/RequestHandlers/Auth/WhoAmIRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Auth/WhoAmIRequestHandler.cs
new file mode 100644
index 0000000..5457b15
--- /dev/null
+++ b/PMDDesktop.Server/RequestHandlers/Auth/WhoAmIRequestHandler.cs
@@ -0,0 +1,23 @@
+using PMDDesktop.Requests.Auth;
+using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
+
+namespace PMDDesktop.Server.RequestHandlers.Auth;
+
+public class WhoAmIRequestHandler : JsonRequestHandler
+{
+
+ protected override async Task CreateResponse(WhoAmIRequest request, GameServer game, User? requestingUser)
+ {
+
+ if (requestingUser is null)
+ throw new UserRequestException($"Please send your valid token in the Authorization header to access this endpoint.");
+
+ return new()
+ {
+ GUID = requestingUser.GUID
+ };
+
+ }
+
+}
diff --git a/PMDDesktop/Requests/Auth/WhoAmIRequest.cs b/PMDDesktop/Requests/Auth/WhoAmIRequest.cs
new file mode 100644
index 0000000..dc72dc6
--- /dev/null
+++ b/PMDDesktop/Requests/Auth/WhoAmIRequest.cs
@@ -0,0 +1,9 @@
+namespace PMDDesktop.Requests.Auth;
+
+[ServerRequest("/api/auth/who-am-i")]
+public sealed class WhoAmIRequest : ServerRequest
+{
+
+ // Expecting Authorization header for this request.
+
+}
diff --git a/PMDDesktop/Requests/Auth/WhoAmIResponse.cs b/PMDDesktop/Requests/Auth/WhoAmIResponse.cs
new file mode 100644
index 0000000..afa4623
--- /dev/null
+++ b/PMDDesktop/Requests/Auth/WhoAmIResponse.cs
@@ -0,0 +1,8 @@
+namespace PMDDesktop.Requests.Auth;
+
+public sealed class WhoAmIResponse : ServerResponse
+{
+
+ public required Guid GUID {get; set;}
+
+}
From 191b3754af9c10218b8bca315167b03dd85fa9be Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Mon, 21 Sep 2026 19:28:02 -0500
Subject: [PATCH 14/55] Add api/users/get-info endpoint
---
.../Users/GetUserInfoRequestHandler.cs | 24 +++++++++++++++++++
.../Requests/Users/GetUserInfoRequest.cs | 10 ++++++++
.../Requests/Users/GetUserInfoResponse.cs | 9 +++++++
3 files changed, 43 insertions(+)
create mode 100644 PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs
create mode 100644 PMDDesktop/Requests/Users/GetUserInfoRequest.cs
create mode 100644 PMDDesktop/Requests/Users/GetUserInfoResponse.cs
diff --git a/PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs
new file mode 100644
index 0000000..b2ccf27
--- /dev/null
+++ b/PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs
@@ -0,0 +1,24 @@
+using PMDDesktop.Requests.Users;
+using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
+
+namespace PMDDesktop.Server.RequestHandlers.Users;
+
+public class GetUserInfoRequestHandler : JsonRequestHandler
+{
+
+ protected override async Task CreateResponse(GetUserInfoRequest request, GameServer game, User? requestingUser)
+ {
+
+ if (!game.TryGetUser(request.GUID, out User? user))
+ throw new UserRequestException($"{request.GUID} does not match a user.");
+
+ return new()
+ {
+ DisplayName = user.Name,
+ GUID = user.GUID
+ };
+
+ }
+
+}
diff --git a/PMDDesktop/Requests/Users/GetUserInfoRequest.cs b/PMDDesktop/Requests/Users/GetUserInfoRequest.cs
new file mode 100644
index 0000000..78d431d
--- /dev/null
+++ b/PMDDesktop/Requests/Users/GetUserInfoRequest.cs
@@ -0,0 +1,10 @@
+namespace PMDDesktop.Requests.Users;
+
+[ServerRequest("/api/users/get-info")]
+public sealed class GetUserInfoRequest : ServerRequest
+{
+
+ [QueryParameter]
+ public required Guid GUID { get; set; }
+
+}
diff --git a/PMDDesktop/Requests/Users/GetUserInfoResponse.cs b/PMDDesktop/Requests/Users/GetUserInfoResponse.cs
new file mode 100644
index 0000000..7583ea9
--- /dev/null
+++ b/PMDDesktop/Requests/Users/GetUserInfoResponse.cs
@@ -0,0 +1,9 @@
+namespace PMDDesktop.Requests.Users;
+
+public sealed class GetUserInfoResponse : ServerResponse
+{
+
+ public required string DisplayName { get; set; }
+ public required Guid GUID { get; set; }
+
+}
From 307495499128729f991ce4e503f1c885d847ebc8 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Tue, 22 Sep 2026 07:37:23 -0500
Subject: [PATCH 15/55] Add User saving and loading.
---
.../PMDDesktop.Server.Users.csproj | 6 +-
PMDDesktop.Server.Users/User.cs | 63 +++++++---
PMDDesktop.Server.Users/UserConverter.cs | 114 ++++++++++++++++++
PMDDesktop.Server.Users/UserManager.cs | 82 +++++++++++++
PMDDesktop.Server/Game/GameState.cs | 1 +
5 files changed, 251 insertions(+), 15 deletions(-)
create mode 100644 PMDDesktop.Server.Users/UserConverter.cs
diff --git a/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj b/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj
index b93788b..1e2e7de 100644
--- a/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj
+++ b/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj
@@ -8,7 +8,11 @@
-
+
+
+
+
+
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index 54710d9..b5cc339 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -1,18 +1,25 @@
using Microsoft.AspNetCore.Identity;
using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
using System.Text.Json.Serialization;
namespace PMDDesktop.Server.Users;
+[JsonConverter(typeof(UserConverter))]
public sealed class User
{
private static PasswordHasher PASSWORD_HASHER = new();
///
- /// A list
+ /// The name of the file that data should be serialized to.
///
- private static string[] DELETE_USER_FOLDER_ITEMS = ["user.json", "picture.jpg", "picture.png"];
+ public static readonly string USER_FILE_NAME = "user.json";
+
+ ///
+ /// A list of items to delete when deleting a .
+ ///
+ private static string[] DELETE_USER_FOLDER_ITEMS = [USER_FILE_NAME, "picture.jpg", "picture.png"];
///
/// Limit of s assigned to one .
@@ -41,14 +48,14 @@ internal User()
/// Should be unique.
///
[JsonInclude]
- public string? LoginHandle { get; private set; }
+ public string? LoginHandle { get; internal set; }
///
/// Hashed password used to log in.
/// In the event that it is null, access to this account cannot be granted via password.
///
[JsonInclude]
- private string? HashedPassword { get; set; }
+ internal string? HashedPassword { get; set; }
///
/// A unique for each . Stays consistant even when the 's handle changes.
@@ -61,11 +68,21 @@ internal User()
///
///
[JsonIgnore]
- public UserManager? Manager {get; private set;}
+ public UserManager? Manager { get; private set; }
+
+ public bool? WritingEnabled { get; private set; }
+
+ internal List accessTokens = [];
+
+ public async Task SetName(string newName)
+ {
+
+ Name = newName;
- public bool? WritingEnabled {get; private set;}
+ if (Manager is not null)
+ await WriteNewData();
- private List accessTokens = [];
+ }
///
/// Salt and hash the user's password, then store it in HashedPassword.
@@ -77,6 +94,9 @@ public async Task SetPassword(string plainText)
HashedPassword = PASSWORD_HASHER.HashPassword(this, plainText);
+ if (Manager is not null)
+ await WriteNewData();
+
}
///
@@ -101,6 +121,9 @@ public async Task TrySetLoginHandle(string? newHandle)
AttachLoginHandle();
+ if (Manager is not null)
+ await WriteNewData();
+
return true;
}
@@ -173,7 +196,7 @@ internal string GetUserFolder()
public string GetUserJSONPath()
{
- return Path.Combine(GetUserFolder(), "user.json");
+ return Path.Combine(GetUserFolder(), USER_FILE_NAME);
}
@@ -187,10 +210,16 @@ internal async Task WriteNewData()
if (WritingEnabled is null)
throw new InvalidOperationException($"{this} shouldn't be deleting its data while {WritingEnabled} is null!");
+ foreach (UserAccessToken token in accessTokens)
+ if (DateTime.UtcNow >= token.ExpiryDate)
+ await RevokeAccessToken(token, false);
+
if (WritingEnabled != true)
return;
- throw new NotImplementedException();
+ using FileStream jsonFile = File.OpenWrite(GetUserJSONPath());
+
+ await JsonSerializer.SerializeAsync(jsonFile, this, AppInfo.JSON_OPTIONS);
}
@@ -251,7 +280,7 @@ public async Task CreateAccessToken()
{
while (accessTokens.Count >= ACCESS_TOKEN_LIMIT)
- await TryRevokeOldestAccessToken();
+ await TryRevokeOldestAccessToken(false);
UserAccessToken token = UserAccessToken.CreateNewToken(this);
@@ -259,6 +288,9 @@ public async Task CreateAccessToken()
AttachAccessToken(token);
+ if (Manager is not null)
+ await WriteNewData();
+
return token;
}
@@ -273,13 +305,16 @@ private void AttachAccessToken(UserAccessToken token)
}
- public async Task RevokeAccessToken(UserAccessToken token)
+ public async Task RevokeAccessToken(UserAccessToken token, bool writeAfterwards = true)
{
accessTokens.Remove(token);
DetachAccessToken(token);
+ if (Manager is not null && writeAfterwards)
+ await WriteNewData();
+
}
private void DetachAccessToken(UserAccessToken token)
@@ -289,7 +324,7 @@ private void DetachAccessToken(UserAccessToken token)
}
- private async Task TryRevokeOldestAccessToken()
+ private async Task TryRevokeOldestAccessToken(bool writeAfterwards = true)
{
UserAccessToken? oldest = null;
@@ -307,7 +342,7 @@ private async Task TryRevokeOldestAccessToken()
if (oldest is null)
return false;
- await RevokeAccessToken(oldest);
+ await RevokeAccessToken(oldest, writeAfterwards);
return true;
}
@@ -330,7 +365,7 @@ internal void AttachToManager(UserManager manager)
internal void DetachFromManager()
{
- if (Manager==null)
+ if (Manager == null)
throw new NullReferenceException($"{nameof(Manager)} is null. Cannot detach from nothing. Does this call to {nameof(DetachFromManager)} need to be skipped?");
if (Manager.guids[GUID] != this)
diff --git a/PMDDesktop.Server.Users/UserConverter.cs b/PMDDesktop.Server.Users/UserConverter.cs
new file mode 100644
index 0000000..ae021f0
--- /dev/null
+++ b/PMDDesktop.Server.Users/UserConverter.cs
@@ -0,0 +1,114 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace PMDDesktop.Server.Users;
+
+
+internal class UserConverter : JsonConverter
+{
+
+ public static readonly string TOKENS_PROPERTY_NAME = "Tokens";
+ public static readonly string CONFIG_PROPERTY_NAME = "Config";
+ public static readonly string TOKEN_STRING_PROPERTY_NAME = "String";
+ public static readonly string TOKEN_EXPIRE_PROPERTY_NAME = "ExpiryDate";
+
+ public override User Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+
+ using JsonDocument json = JsonDocument.ParseValue(ref reader);
+
+ List propertyList = [.. json.RootElement.EnumerateObject().Select(element => element.Name)];
+
+ User user = new()
+ {
+ Name = QuickReadString(json, propertyList, nameof(User.Name)),
+ HashedPassword = QuickReadNullableString(json, propertyList, nameof(User.HashedPassword)),
+ LoginHandle = QuickReadNullableString(json, propertyList, nameof(User.LoginHandle))
+ };
+
+ propertyList.Remove(TOKENS_PROPERTY_NAME);
+ user.accessTokens = ReadTokenArray(json, user);
+
+ if (propertyList.Count > 0)
+ throw new JsonException($"The user json had left-over properties after parsing, such as {propertyList[0]}. These unused properties cannot be accounted for, and may result in data loss. Is it from a newer version?");
+
+ if (user.IsAlive())
+ throw new InvalidOperationException($"{user} should not be attached to a manager at this point, or otherwise \"alive\".");
+
+ return user;
+
+ }
+
+ public override void Write(Utf8JsonWriter writer, User value, JsonSerializerOptions options)
+ {
+
+ writer.WriteStartObject();
+
+ writer.WriteString(nameof(User.Name), value.Name);
+ writer.WriteString(nameof(User.HashedPassword), value.HashedPassword);
+ writer.WriteString(nameof(User.LoginHandle), value.LoginHandle);
+
+ writer.WriteStartArray(TOKENS_PROPERTY_NAME);
+ foreach (UserAccessToken token in value.accessTokens)
+ WriteToken(writer, token);
+ writer.WriteEndArray();
+
+ writer.WriteEndObject();
+
+ }
+
+ private static void WriteToken(Utf8JsonWriter writer, UserAccessToken token)
+ {
+
+ writer.WriteStartObject();
+
+ writer.WriteString(TOKEN_STRING_PROPERTY_NAME, token.TokenString);
+ writer.WriteString(TOKEN_EXPIRE_PROPERTY_NAME, token.ExpiryDate);
+
+ writer.WriteEndObject();
+
+ }
+
+ private static string QuickReadString(JsonDocument document, List propertyList, string name)
+ {
+
+ propertyList.Remove(name);
+
+ return document.RootElement.GetProperty(name).GetString()
+ ?? throw new JsonException($"{name} should not be null in {document}");
+
+ }
+
+ private static string? QuickReadNullableString(JsonDocument document, List propertyList, string name)
+ {
+
+ propertyList.Remove(name);
+
+ return document.RootElement.GetProperty(name).GetString();
+
+ }
+
+ private static List ReadTokenArray(JsonDocument document, User user)
+ {
+
+ List tokens = [];
+
+ JsonElement tokenJsonArray = document.RootElement.GetProperty(TOKENS_PROPERTY_NAME);
+
+ foreach (JsonElement token in tokenJsonArray.EnumerateArray())
+ {
+
+ string tokenString = token.GetProperty(TOKEN_STRING_PROPERTY_NAME).GetString()
+ ?? throw new JsonException($"{TOKEN_STRING_PROPERTY_NAME} should not be null in {token}");
+
+ DateTime expiryDate = token.GetProperty(TOKEN_EXPIRE_PROPERTY_NAME).GetDateTime();
+
+ tokens.Add(new(tokenString, user, expiryDate));
+
+ }
+
+ return tokens;
+
+ }
+
+}
diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs
index d09097a..b9421fa 100644
--- a/PMDDesktop.Server.Users/UserManager.cs
+++ b/PMDDesktop.Server.Users/UserManager.cs
@@ -1,5 +1,6 @@
using System.Collections;
using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
namespace PMDDesktop.Server.Users;
@@ -39,6 +40,87 @@ public sealed class UserManager() : IEnumerable, IUserIndexable
///
internal Dictionary accessTokens = [];
+ ///
+ /// Loads from the 'users' and 'roles' directories, and enables .
+ ///
+ ///
+ /// This can throw under many, many circumstances. If it does, cancel all operations and make sure the error is conveyed to the user.
+ ///
+ [ExcludeFromCodeCoverage]
+ public async Task LoadFromFilesAndEnableWriting()
+ {
+
+ // Since Users check the WritingEnabled value, we need to set this before loading.
+ WritingEnabled = true;
+
+ await LoadAllUserData();
+
+ await LoadAllRoleData();
+
+ }
+
+ ///
+ /// This manages creating objects by loading their data from the "users" folder.
+ ///
+ [ExcludeFromCodeCoverage]
+ private async Task LoadAllUserData()
+ {
+
+ string userRootDirPath = Path.Combine(AppContext.BaseDirectory, "users");
+
+ if (!Directory.Exists(userRootDirPath))
+ Directory.CreateDirectory(userRootDirPath);
+
+ foreach (string subDirPath in Directory.EnumerateDirectories(userRootDirPath))
+ {
+
+ string guidParsable = Path.GetFileName(subDirPath);
+
+ // This is not okay. All user folders should have GUIDs are their names.
+ if (!Guid.TryParse(guidParsable, out Guid loadedGUID))
+ throw new Exception($"Couldn't parse {guidParsable} as a GUID at {subDirPath}");
+
+ string userFilePath = Path.Join(subDirPath, User.USER_FILE_NAME);
+
+ using FileStream readStream = File.OpenRead(userFilePath);
+
+ await LoadAndAddUserJson(readStream, loadedGUID);
+
+ }
+
+ }
+
+ ///
+ /// Internal function for loading a JSON stream and adding the resulting to this .
+ ///
+ /// A JSON stream with data inside.
+ ///
+ ///
+ /// This can be safely unit tested, as it takes in a stream instead of reading from a file.
+ ///
+ internal async Task LoadAndAddUserJson(Stream stream, Guid guid)
+ {
+
+ User deserialized = JsonSerializer.Deserialize(stream, AppInfo.JSON_OPTIONS)
+ ?? throw new Exception($"Deserialized save data from {stream} was null.");
+
+ deserialized.GUID = guid; // GUID will be randomized by default, we need to load the previous GUID.
+
+ deserialized.AttachToManager(this);
+
+ }
+
+ ///
+ /// This manages creating objects by loading their data from the "users" folder.
+ ///
+ [ExcludeFromCodeCoverage]
+ private async Task LoadAllRoleData()
+ {
+
+ // Not yet implemented.
+
+ }
+
public IEnumerator GetEnumerator()
{
return guids.Values.GetEnumerator();
diff --git a/PMDDesktop.Server/Game/GameState.cs b/PMDDesktop.Server/Game/GameState.cs
index d8c80b8..5212ecb 100644
--- a/PMDDesktop.Server/Game/GameState.cs
+++ b/PMDDesktop.Server/Game/GameState.cs
@@ -26,6 +26,7 @@ public async Task LoadAllFiles()
await Saves.LoadFromFilesAndEnableWriting();
await Assets.LoadFromFiles();
+ await Users.LoadFromFilesAndEnableWriting();
}
From 972ee794adb15f45b1c77f74afa2cdad48747bca Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Tue, 22 Sep 2026 07:50:44 -0500
Subject: [PATCH 16/55] Fix written file not being truncated.
---
PMDDesktop.Server.Users/User.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index b5cc339..0cd8f6a 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -217,7 +217,7 @@ internal async Task WriteNewData()
if (WritingEnabled != true)
return;
- using FileStream jsonFile = File.OpenWrite(GetUserJSONPath());
+ using FileStream jsonFile = File.Create(GetUserJSONPath());
await JsonSerializer.SerializeAsync(jsonFile, this, AppInfo.JSON_OPTIONS);
From 85a6329b43821b4ca53d55a79f219408161fe142 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Tue, 22 Sep 2026 13:14:07 -0500
Subject: [PATCH 17/55] Add UserConverterTests and return User in
LoadAndAddUserJson
---
.../UserConverterTests.cs | 80 +++++++++++++++++++
PMDDesktop.Server.Users/UserManager.cs | 4 +-
2 files changed, 83 insertions(+), 1 deletion(-)
create mode 100644 PMDDesktop.Server.Users.Tests/UserConverterTests.cs
diff --git a/PMDDesktop.Server.Users.Tests/UserConverterTests.cs b/PMDDesktop.Server.Users.Tests/UserConverterTests.cs
new file mode 100644
index 0000000..2643302
--- /dev/null
+++ b/PMDDesktop.Server.Users.Tests/UserConverterTests.cs
@@ -0,0 +1,80 @@
+using System.Text.Json;
+
+namespace PMDDesktop.Server.Users.Tests;
+
+public class UserConverterTests
+{
+
+ public static readonly UserCreationOptions DEFAULT_USER_OPTIONS = new()
+ {
+ DisplayName = "Test User",
+ LoginHandle = "test",
+ PlainTextPassword = "Test123"
+ };
+
+ [Fact]
+ public async Task ToAndFromJsonKeepsName()
+ {
+
+ UserManager manager1 = new();
+ User? user1 = await manager1.TryCreateUser(DEFAULT_USER_OPTIONS);
+ Assert.NotNull(user1);
+
+ using MemoryStream jsonStream = new();
+
+ await JsonSerializer.SerializeAsync(jsonStream, user1, AppInfo.JSON_OPTIONS);
+
+ jsonStream.Position = 0; // Rewind the stream so it can be read.
+
+ UserManager manager2 = new();
+ User user2 = await manager2.LoadAndAddUserJson(jsonStream, user1.GUID);
+
+ Assert.Equal(user1.GUID, user2.GUID);
+ Assert.Equal(user1.Name, user2.Name);
+ Assert.Equal(user1.HashedPassword, user2.HashedPassword);
+ Assert.Equal(user1.LoginHandle, user2.LoginHandle);
+
+ }
+
+ [Fact]
+ public async Task ToAndFromJsonReattachesTokens()
+ {
+
+ UserManager manager1 = new();
+ User? user1 = await manager1.TryCreateUser(DEFAULT_USER_OPTIONS);
+ Assert.NotNull(user1);
+
+ for (int i = 0; i < 100; ++i) // Creating an excess so we make sure that discarded tokens are also treated correctly.
+ await user1.CreateAccessToken();
+
+ Assert.NotEmpty(manager1.accessTokens);
+ Assert.NotEmpty(user1.accessTokens);
+
+ using MemoryStream jsonStream = new();
+
+ await JsonSerializer.SerializeAsync(jsonStream, user1, AppInfo.JSON_OPTIONS);
+
+ jsonStream.Position = 0; // Rewind the stream so it can be read.
+
+ UserManager manager2 = new();
+ User user2 = await manager2.LoadAndAddUserJson(jsonStream, user1.GUID);
+
+ Assert.NotEmpty(manager2.accessTokens);
+ Assert.NotEmpty(user2.accessTokens);
+
+ Assert.All(manager1.accessTokens.Values, originalToken =>
+ {
+
+ Assert.True(manager2.TryUseAccessToken(originalToken.TokenString, out User? user, out UserAccessToken? token));
+
+ Assert.Equal(user2, user);
+ Assert.Equal(originalToken.ExpiryDate, token.ExpiryDate);
+ Assert.Equal(originalToken.TokenString, token.TokenString);
+
+ });
+
+ Assert.Equal(manager1.accessTokens.Count, manager2.accessTokens.Count);
+
+ }
+
+}
diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs
index b9421fa..a3368fd 100644
--- a/PMDDesktop.Server.Users/UserManager.cs
+++ b/PMDDesktop.Server.Users/UserManager.cs
@@ -98,7 +98,7 @@ private async Task LoadAllUserData()
///
/// This can be safely unit tested, as it takes in a stream instead of reading from a file.
///
- internal async Task LoadAndAddUserJson(Stream stream, Guid guid)
+ internal async Task LoadAndAddUserJson(Stream stream, Guid guid)
{
User deserialized = JsonSerializer.Deserialize(stream, AppInfo.JSON_OPTIONS)
@@ -108,6 +108,8 @@ internal async Task LoadAndAddUserJson(Stream stream, Guid guid)
deserialized.AttachToManager(this);
+ return deserialized;
+
}
///
From df103c8f0a52f5c1ccf244bd9cf4453be27843fe Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:18:19 -0500
Subject: [PATCH 18/55] Add UserRole and UserPermission with tests
---
.../Roles/UserPermissionTests.cs | 18 +++
.../Roles/UserRoleConverterTests.cs | 126 ++++++++++++++++++
.../Roles/UserRoleTests.cs | 33 +++++
.../Roles/UserPermission.cs | 87 ++++++++++++
PMDDesktop.Server.Users/Roles/UserRole.cs | 35 +++++
.../Roles/UserRoleConverter.cs | 105 +++++++++++++++
6 files changed, 404 insertions(+)
create mode 100644 PMDDesktop.Server.Users.Tests/Roles/UserPermissionTests.cs
create mode 100644 PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs
create mode 100644 PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs
create mode 100644 PMDDesktop.Server.Users/Roles/UserPermission.cs
create mode 100644 PMDDesktop.Server.Users/Roles/UserRole.cs
create mode 100644 PMDDesktop.Server.Users/Roles/UserRoleConverter.cs
diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserPermissionTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserPermissionTests.cs
new file mode 100644
index 0000000..19aa2d3
--- /dev/null
+++ b/PMDDesktop.Server.Users.Tests/Roles/UserPermissionTests.cs
@@ -0,0 +1,18 @@
+using PMDDesktop.Server.Users.Roles;
+
+namespace PMDDesktop.Server.Users.Tests.Roles;
+
+public class UserPermissionTests
+{
+
+ [Theory]
+ [InlineData("manage-users")]
+ [InlineData("manage-roles")]
+ public void UserPermissionAllIncludesPermissions(string dataName)
+ {
+
+ Assert.Contains(UserPermission.ALL, permission => permission.DataName == dataName);
+
+ }
+
+}
diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs
new file mode 100644
index 0000000..a7bd7df
--- /dev/null
+++ b/PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs
@@ -0,0 +1,126 @@
+using System.Text.Json;
+using PMDDesktop.Server.Users.Roles;
+
+namespace PMDDesktop.Server.Users.Tests.Roles;
+
+public class UserRoleConverterTests
+{
+
+ [Fact]
+ public async Task RoleJsonContainsNullsForUnspecified()
+ {
+
+ UserRole role = new();
+
+ await role.SetPermission(UserPermission.MANAGE_USERS, true);
+
+ JsonDocument document = JsonSerializer.SerializeToDocument(role);
+
+ JsonElement manageRolesElement = document.RootElement.GetProperty(UserRoleConverter.PERMISSIONS_PROPERTY).GetProperty(UserPermission.MANAGE_ROLES.DataName);
+
+ Assert.Equal(JsonValueKind.Null, manageRolesElement.ValueKind);
+
+ }
+
+ [Fact]
+ public async Task RoleJsonContainsTrueWhenSpecified()
+ {
+
+ UserRole role = new();
+
+ await role.SetPermission(UserPermission.MANAGE_USERS, true);
+
+ JsonDocument document = JsonSerializer.SerializeToDocument(role);
+
+ JsonElement manageRolesElement = document.RootElement.GetProperty(UserRoleConverter.PERMISSIONS_PROPERTY).GetProperty(UserPermission.MANAGE_USERS.DataName);
+
+ Assert.Equal(JsonValueKind.True, manageRolesElement.ValueKind);
+
+ }
+
+ [Fact]
+ public async Task RoleJsonContainsFalseWhenSpecified()
+ {
+
+ UserRole role = new();
+
+ await role.SetPermission(UserPermission.MANAGE_USERS, false);
+
+ JsonDocument document = JsonSerializer.SerializeToDocument(role);
+
+ JsonElement manageRolesElement = document.RootElement.GetProperty(UserRoleConverter.PERMISSIONS_PROPERTY).GetProperty(UserPermission.MANAGE_USERS.DataName);
+
+ Assert.Equal(JsonValueKind.False, manageRolesElement.ValueKind);
+
+ }
+
+ [Fact]
+ public async Task RoleJsonWithSpecifiedPermissionThenUnspecifiedContainsNull()
+ {
+
+ UserRole role = new();
+
+ await role.SetPermission(UserPermission.MANAGE_USERS, true);
+ await role.SetPermission(UserPermission.MANAGE_USERS, null);
+
+ JsonDocument document = JsonSerializer.SerializeToDocument(role);
+
+ JsonElement manageRolesElement = document.RootElement.GetProperty(UserRoleConverter.PERMISSIONS_PROPERTY).GetProperty(UserPermission.MANAGE_USERS.DataName);
+
+ Assert.Equal(JsonValueKind.Null, manageRolesElement.ValueKind);
+
+ }
+
+ [Fact]
+ public async Task RoundTripRoleWithSpecifiedPermissionKeepsTrue()
+ {
+
+ UserRole role = new();
+
+ await role.SetPermission(UserPermission.MANAGE_ROLES, true);
+
+ JsonDocument document = JsonSerializer.SerializeToDocument(role);
+
+ UserRole? newRole = JsonSerializer.Deserialize(document);
+ Assert.NotNull(newRole);
+
+ Assert.Equal(true, newRole.GetPermission(UserPermission.MANAGE_ROLES));
+
+ }
+
+ [Fact]
+ public async Task RoundTripRoleWithSpecifiedPermissionKeepsFalse()
+ {
+
+ UserRole role = new();
+
+ await role.SetPermission(UserPermission.MANAGE_ROLES, false);
+
+ JsonDocument document = JsonSerializer.SerializeToDocument(role);
+
+ UserRole? newRole = JsonSerializer.Deserialize(document);
+ Assert.NotNull(newRole);
+
+ Assert.Equal(false, newRole.GetPermission(UserPermission.MANAGE_ROLES));
+
+ }
+
+ [Fact]
+ public async Task RoundTripRoleWithSpecifiedPermissionKeepsNull()
+ {
+
+ UserRole role = new();
+
+ await role.SetPermission(UserPermission.MANAGE_USERS, null);
+
+ JsonDocument document = JsonSerializer.SerializeToDocument(role);
+
+ UserRole? newRole = JsonSerializer.Deserialize(document);
+ Assert.NotNull(newRole);
+
+ Assert.Null(newRole.GetPermission(UserPermission.MANAGE_USERS));
+ Assert.Null(newRole.GetPermission(UserPermission.MANAGE_ROLES));
+
+ }
+
+}
diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs
new file mode 100644
index 0000000..0dd55d3
--- /dev/null
+++ b/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs
@@ -0,0 +1,33 @@
+using PMDDesktop.Server.Users.Roles;
+
+namespace PMDDesktop.Server.Users.Tests.Roles;
+
+public class UserRoleTests
+{
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ [InlineData(null)]
+ public async Task SetPermissionsAndReturnExpectedPermissions(bool? value)
+ {
+
+ UserRole role = new();
+
+ await role.SetPermission(UserPermission.MANAGE_ROLES, value);
+
+ Assert.Equal(value, role.GetPermission(UserPermission.MANAGE_ROLES));
+
+ }
+
+ [Fact]
+ public async Task UnsetPermissionsAreNull()
+ {
+
+ UserRole role = new();
+
+ Assert.All(UserPermission.ALL, permission => Assert.Null(role.GetPermission(permission)));
+
+ }
+
+}
diff --git a/PMDDesktop.Server.Users/Roles/UserPermission.cs b/PMDDesktop.Server.Users/Roles/UserPermission.cs
new file mode 100644
index 0000000..0c8519e
--- /dev/null
+++ b/PMDDesktop.Server.Users/Roles/UserPermission.cs
@@ -0,0 +1,87 @@
+using System.Collections.Immutable;
+using System.Reflection;
+
+namespace PMDDesktop.Server.Users.Roles;
+
+public sealed class UserPermission
+{
+
+ static UserPermission()
+ {
+
+ FieldInfo[] fields = typeof(UserPermission).GetFields(BindingFlags.Static | BindingFlags.Public);
+
+ IEnumerable permissionFields = fields.Where(field => field.FieldType.IsAssignableTo(typeof(UserPermission)));
+
+ IEnumerable permissionObjects = permissionFields.Select(field =>
+ (UserPermission?) field.GetValue(ALL)
+ ?? throw new NullReferenceException($"Unable to convert {field} to {nameof(UserPermission)}"));
+
+ ALL = ImmutableArray.Create([.. permissionObjects]);
+
+ }
+
+ public static readonly ImmutableArray ALL;
+
+ public static readonly UserPermission MANAGE_USERS = new()
+ {
+ DataName = "manage-users",
+ FriendlyName = "Manage Users",
+ FriendlyDescription = "Allow creation, modification, and deletion of user accounts.",
+ AlwaysForAdmin = true,
+ EnabledForDefault = false
+ };
+
+ public static readonly UserPermission MANAGE_ROLES = new()
+ {
+ DataName = "manage-roles",
+ FriendlyName = "Manage Roles",
+ FriendlyDescription = "Allow creation, modification, and removal of roles and their permissions.",
+ AlwaysForAdmin = true,
+ EnabledForDefault = false
+ };
+
+ ///
+ /// Name that is stored and compared to track permission in a .
+ ///
+ public required string DataName {get; init;}
+
+ ///
+ /// User-facing name that is displayed to users when viewing permissions.
+ ///
+ public required string FriendlyName {get; init;}
+
+ ///
+ /// User-facing description for what a permission does.
+ ///
+ public required string FriendlyDescription {get; init;}
+
+ ///
+ /// Weather to always grant this permission for admin users, to prevent losing ability to regain the permission.
+ ///
+ public required bool AlwaysForAdmin {get; init;}
+
+ public required bool EnabledForDefault {get; init;}
+
+ private UserPermission() { }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(DataName);
+ }
+
+ public override bool Equals(object? obj)
+ {
+
+ if (obj is UserPermission permission)
+ return permission.DataName == DataName;
+
+ return base.Equals(obj);
+ }
+
+ public override string ToString()
+ {
+ return $"{nameof(UserPermission)}:{DataName}";
+ }
+
+}
diff --git a/PMDDesktop.Server.Users/Roles/UserRole.cs b/PMDDesktop.Server.Users/Roles/UserRole.cs
new file mode 100644
index 0000000..326eff0
--- /dev/null
+++ b/PMDDesktop.Server.Users/Roles/UserRole.cs
@@ -0,0 +1,35 @@
+using System.Text.Json.Serialization;
+
+namespace PMDDesktop.Server.Users.Roles;
+
+[JsonConverter(typeof(UserRoleConverter))]
+public sealed class UserRole
+{
+
+ internal UserRole() { }
+
+ internal Dictionary permissionSettings = [];
+ public string Name {get; set;} = "Unnamed Role";
+ public Guid guid = Guid.NewGuid();
+
+ public async Task SetPermission(UserPermission permission, bool? preference)
+ {
+
+ if (preference is bool boolPreference)
+ permissionSettings[permission] = boolPreference;
+ else
+ permissionSettings.Remove(permission);
+
+ }
+
+ public bool? GetPermission(UserPermission permission)
+ {
+
+ if (permissionSettings.TryGetValue(permission, out bool value))
+ return value;
+
+ return null;
+
+ }
+
+}
diff --git a/PMDDesktop.Server.Users/Roles/UserRoleConverter.cs b/PMDDesktop.Server.Users/Roles/UserRoleConverter.cs
new file mode 100644
index 0000000..e42d2c7
--- /dev/null
+++ b/PMDDesktop.Server.Users/Roles/UserRoleConverter.cs
@@ -0,0 +1,105 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace PMDDesktop.Server.Users.Roles;
+
+public class UserRoleConverter : JsonConverter
+{
+
+ internal static readonly string NAME_PROPERTY = "Name";
+ internal static readonly string PERMISSIONS_PROPERTY = "Permissions";
+
+ public override UserRole? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+
+ using JsonDocument document = JsonDocument.ParseValue(ref reader);
+
+ List propertyList = [.. document.RootElement.EnumerateObject().Select(element => element.Name)];
+
+ UserRole role = new()
+ {
+ Name = QuickReadString(document, propertyList, NAME_PROPERTY)
+ };
+
+ propertyList.Remove(PERMISSIONS_PROPERTY);
+ role.permissionSettings = ReadPermissions(document);
+
+ if (propertyList.Count > 0)
+ throw new JsonException($"The {nameof(UserRole)} json had left-over properties after parsing, such as {propertyList[0]}. These unused properties cannot be accounted for, and may result in data loss. Is it from a newer version?");
+
+ return role;
+
+ }
+
+ public override void Write(Utf8JsonWriter writer, UserRole value, JsonSerializerOptions options)
+ {
+
+ writer.WriteStartObject();
+
+ // Write name
+ writer.WriteString(NAME_PROPERTY, value.Name);
+
+ // Write permissions
+ writer.WriteStartObject(PERMISSIONS_PROPERTY);
+ foreach (UserPermission permission in UserPermission.ALL)
+ QuickWritePermission(writer, permission, value);
+ writer.WriteEndObject();
+
+ writer.WriteEndObject();
+
+ }
+
+ private static void QuickWritePermission(Utf8JsonWriter writer, UserPermission permission, UserRole value)
+ {
+
+ bool? result = value.GetPermission(permission);
+
+ if (result is bool boolResult)
+ writer.WriteBoolean(permission.DataName, boolResult);
+ else
+ writer.WriteNull(permission.DataName);
+
+ }
+
+ private static string QuickReadString(JsonDocument document, List propertyList, string name)
+ {
+
+ propertyList.Remove(name);
+
+ return document.RootElement.GetProperty(name).GetString()
+ ?? throw new JsonException($"{name} should not be null in {document}");
+
+ }
+
+ private static Dictionary ReadPermissions(JsonDocument document)
+ {
+
+ Dictionary permissionSettings = [];
+ JsonElement jsonObj = document.RootElement.GetProperty(PERMISSIONS_PROPERTY);
+
+ foreach (JsonProperty property in jsonObj.EnumerateObject())
+ {
+
+ string dataName = property.Name;
+
+ // We get this first, because if we can't find a permission even for a "null" permission, there's a chance we could be causing data loss in a different role.
+ UserPermission permission = UserPermission.ALL.First(perm => perm.DataName == dataName);
+
+ bool? value = property.Value.ValueKind switch
+ {
+ JsonValueKind.True => true,
+ JsonValueKind.False => false,
+ JsonValueKind.Null => null,
+ _ => throw new JsonException($"Unsupported value kind for {dataName}: {property.Value.ValueKind}")
+ };
+
+ if (value is bool boolValue)
+ permissionSettings.Add(permission, boolValue);
+
+ }
+
+ return permissionSettings;
+
+ }
+
+}
From 5ee04ebe21bf1186bc83fe517bce8f67d93d998a Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:18:37 -0500
Subject: [PATCH 19/55] Disable default launch settings file
---
PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj | 1 +
1 file changed, 1 insertion(+)
diff --git a/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj b/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj
index 1e2e7de..70d22b0 100644
--- a/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj
+++ b/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj
@@ -5,6 +5,7 @@
net10.0
enable
enable
+ true
From 83d896aaa3cf7eabd9d786e01e16d5da98f83c21 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Sat, 26 Sep 2026 16:28:46 -0500
Subject: [PATCH 20/55] add "default" role creation method
---
.../Roles/UserRoleTests.cs | 20 +++++++++++++++++++
PMDDesktop.Server.Users/Roles/UserRole.cs | 18 ++++++++++++++++-
2 files changed, 37 insertions(+), 1 deletion(-)
diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs
index 0dd55d3..0bade5e 100644
--- a/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs
+++ b/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs
@@ -30,4 +30,24 @@ public async Task UnsetPermissionsAreNull()
}
+ [Fact]
+ public void DefaultRoleHasAllEnabledByDefaultPermissions()
+ {
+
+ UserRole role = UserRole.CreateDefaultRole();
+
+ Assert.All(UserPermission.ALL.Where(perm => perm.EnabledForDefault), perm => Assert.True(role.GetPermission(perm)));
+
+ }
+
+ [Fact]
+ public void DefaultRoleHasNoDisabledByDefaultPermissions()
+ {
+
+ UserRole role = UserRole.CreateDefaultRole();
+
+ Assert.All(UserPermission.ALL.Where(perm => !perm.EnabledForDefault), perm => Assert.Null(role.GetPermission(perm)));
+
+ }
+
}
diff --git a/PMDDesktop.Server.Users/Roles/UserRole.cs b/PMDDesktop.Server.Users/Roles/UserRole.cs
index 326eff0..51cc6dc 100644
--- a/PMDDesktop.Server.Users/Roles/UserRole.cs
+++ b/PMDDesktop.Server.Users/Roles/UserRole.cs
@@ -10,7 +10,7 @@ internal UserRole() { }
internal Dictionary permissionSettings = [];
public string Name {get; set;} = "Unnamed Role";
- public Guid guid = Guid.NewGuid();
+ public Guid GUID { get; internal set; } = Guid.NewGuid();
public async Task SetPermission(UserPermission permission, bool? preference)
{
@@ -32,4 +32,20 @@ public async Task SetPermission(UserPermission permission, bool? preference)
}
+ internal static UserRole CreateDefaultRole()
+ {
+
+ UserRole role = new()
+ {
+ GUID = Guid.Empty,
+ Name = "Default (Everyone)"
+ };
+
+ foreach (UserPermission permission in UserPermission.ALL.Where(perm => perm.EnabledForDefault))
+ role.permissionSettings.Add(permission, true);
+
+ return role;
+
+ }
+
}
From 111e3960a9f3b659f265c2dd288ff3ed45eea90b Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Sat, 26 Sep 2026 17:00:31 -0500
Subject: [PATCH 21/55] Trim test RoundTripWithSpecifiedPermissionKeepsValue()
---
.../Roles/UserRoleConverterTests.cs | 50 ++++---------------
1 file changed, 9 insertions(+), 41 deletions(-)
diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs
index a7bd7df..8183305 100644
--- a/PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs
+++ b/PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs
@@ -1,5 +1,5 @@
-using System.Text.Json;
-using PMDDesktop.Server.Users.Roles;
+using PMDDesktop.Server.Users.Roles;
+using System.Text.Json;
namespace PMDDesktop.Server.Users.Tests.Roles;
@@ -71,55 +71,23 @@ public async Task RoleJsonWithSpecifiedPermissionThenUnspecifiedContainsNull()
}
- [Fact]
- public async Task RoundTripRoleWithSpecifiedPermissionKeepsTrue()
- {
-
- UserRole role = new();
-
- await role.SetPermission(UserPermission.MANAGE_ROLES, true);
-
- JsonDocument document = JsonSerializer.SerializeToDocument(role);
-
- UserRole? newRole = JsonSerializer.Deserialize(document);
- Assert.NotNull(newRole);
-
- Assert.Equal(true, newRole.GetPermission(UserPermission.MANAGE_ROLES));
-
- }
-
- [Fact]
- public async Task RoundTripRoleWithSpecifiedPermissionKeepsFalse()
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ [InlineData(null)]
+ public async Task RoundTripRoleWithSpecifiedPermissionKeepsValue(bool? value)
{
UserRole role = new();
- await role.SetPermission(UserPermission.MANAGE_ROLES, false);
-
- JsonDocument document = JsonSerializer.SerializeToDocument(role);
-
- UserRole? newRole = JsonSerializer.Deserialize(document);
- Assert.NotNull(newRole);
-
- Assert.Equal(false, newRole.GetPermission(UserPermission.MANAGE_ROLES));
-
- }
-
- [Fact]
- public async Task RoundTripRoleWithSpecifiedPermissionKeepsNull()
- {
-
- UserRole role = new();
-
- await role.SetPermission(UserPermission.MANAGE_USERS, null);
+ await role.SetPermission(UserPermission.MANAGE_USERS, value);
JsonDocument document = JsonSerializer.SerializeToDocument(role);
UserRole? newRole = JsonSerializer.Deserialize(document);
Assert.NotNull(newRole);
- Assert.Null(newRole.GetPermission(UserPermission.MANAGE_USERS));
- Assert.Null(newRole.GetPermission(UserPermission.MANAGE_ROLES));
+ Assert.Equal(value, newRole.GetPermission(UserPermission.MANAGE_USERS));
}
From cf37d01f693b97bb4be94d948131813c2729f575 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Sun, 27 Sep 2026 23:20:51 -0500
Subject: [PATCH 22/55] Add throw for trying to attach user while already
attached
---
PMDDesktop.Server.Users/User.cs | 3 +++
1 file changed, 3 insertions(+)
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index 0cd8f6a..03b2e51 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -350,6 +350,9 @@ private async Task TryRevokeOldestAccessToken(bool writeAfterwards = true)
internal void AttachToManager(UserManager manager)
{
+ if (Manager != null)
+ throw new InvalidOperationException($"This existing value of {nameof(Manager)} is not null. Cannot attach while already attached. Does this call to {nameof(AttachToManager)} need to be skipped, or does {nameof(DetachFromManager)} need to be called first?");
+
Manager = manager;
WritingEnabled = manager.WritingEnabled;
From ff4053f84336293c0ecfc30952f7dcbb37b4dbcf Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Sun, 27 Sep 2026 23:47:52 -0500
Subject: [PATCH 23/55] Add UserRoleManager (no saving yet)
---
.../Roles/UserRoleManagerTests.cs | 194 ++++++++++++++++++
.../Roles/UserRoleTests.cs | 10 +
.../Roles/IRoleIndexable.cs | 13 ++
PMDDesktop.Server.Users/Roles/UserRole.cs | 34 ++-
.../Roles/UserRoleManager.cs | 101 +++++++++
PMDDesktop.Server.Users/UserManager.cs | 23 ++-
6 files changed, 372 insertions(+), 3 deletions(-)
create mode 100644 PMDDesktop.Server.Users.Tests/Roles/UserRoleManagerTests.cs
create mode 100644 PMDDesktop.Server.Users/Roles/IRoleIndexable.cs
create mode 100644 PMDDesktop.Server.Users/Roles/UserRoleManager.cs
diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserRoleManagerTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserRoleManagerTests.cs
new file mode 100644
index 0000000..1373c2f
--- /dev/null
+++ b/PMDDesktop.Server.Users.Tests/Roles/UserRoleManagerTests.cs
@@ -0,0 +1,194 @@
+using PMDDesktop.Server.Users.Roles;
+
+namespace PMDDesktop.Server.Users.Tests.Roles;
+
+public class UserRoleManagerTests
+{
+
+ [Fact]
+ public void UserRoleManagerPointsBackAtUserManager()
+ {
+
+ UserManager manager = new();
+ UserRoleManager roleManager = manager.RoleManager;
+
+ Assert.NotNull(roleManager);
+
+ Assert.Equal(manager, roleManager.Manager);
+
+ Assert.Equal(roleManager, roleManager.Manager.RoleManager);
+
+ }
+
+ [Fact]
+ public void UserRoleManagerHasDefaultRole()
+ {
+
+ UserManager manager = new();
+ UserRoleManager roleManager = manager.RoleManager;
+
+ Assert.NotNull(roleManager.DefaultRole);
+
+ Assert.True(roleManager.DefaultRole.IsDefaultRole);
+
+ Assert.Equal(Guid.Empty, roleManager.DefaultRole.GUID);
+
+ // Seeing if that's what we get for Guid.Empty
+
+ Assert.True(roleManager.TryGetRole(Guid.Empty, out UserRole? tryEmptyRole));
+ Assert.NotNull(tryEmptyRole);
+ Assert.Equal(roleManager.DefaultRole, tryEmptyRole);
+
+ // Test comparing to mock default role.
+
+ UserRole exampleDefault = UserRole.CreateDefaultRole();
+ UserRole managerDefault = roleManager.DefaultRole;
+
+ Assert.All(UserPermission.ALL, perm => Assert.Equal(exampleDefault.GetPermission(perm), managerDefault.GetPermission(perm)));
+
+ }
+
+ [Fact]
+ public async Task NewRolesAreOrderedLastBeforeDefault()
+ {
+
+ UserManager manager = new();
+ UserRoleManager roleManager = manager.RoleManager;
+
+ // Role 0
+ UserRole role0 = roleManager.DefaultRole;
+ Assert.Equal(role0, roleManager.Skip(0).First());
+ Assert.Single(roleManager);
+ Assert.Equal(role0, roleManager.SkipLast(0).Last());
+
+ // Role 1
+
+ UserRole? role1 = await roleManager.TryCreateRole();
+ Assert.Equal(role1, roleManager.Skip(0).First());
+ Assert.Equal(role0, roleManager.Skip(1).First());
+ Assert.Equal(2, roleManager.Count());
+ Assert.Equal(role1, roleManager.SkipLast(1).Last());
+ Assert.Equal(role0, roleManager.SkipLast(0).Last());
+
+ // Role 2
+
+ UserRole? role2 = await roleManager.TryCreateRole();
+ Assert.Equal(role1, roleManager.Skip(0).First());
+ Assert.Equal(role2, roleManager.Skip(1).First());
+ Assert.Equal(role0, roleManager.Skip(2).First());
+ Assert.Equal(3, roleManager.Count());
+ Assert.Equal(role1, roleManager.SkipLast(2).Last());
+ Assert.Equal(role2, roleManager.SkipLast(1).Last());
+ Assert.Equal(role0, roleManager.SkipLast(0).Last());
+
+ // Role 3
+
+ UserRole? role3 = await roleManager.TryCreateRole();
+ Assert.Equal(role1, roleManager.Skip(0).First());
+ Assert.Equal(role2, roleManager.Skip(1).First());
+ Assert.Equal(role3, roleManager.Skip(2).First());
+ Assert.Equal(role0, roleManager.Skip(3).First());
+ Assert.Equal(4, roleManager.Count());
+ Assert.Equal(role1, roleManager.SkipLast(3).Last());
+ Assert.Equal(role2, roleManager.SkipLast(2).Last());
+ Assert.Equal(role3, roleManager.SkipLast(1).Last());
+ Assert.Equal(role0, roleManager.SkipLast(0).Last());
+
+ // Role 4
+
+ UserRole? role4 = await roleManager.TryCreateRole();
+ Assert.Equal(role1, roleManager.Skip(0).First());
+ Assert.Equal(role2, roleManager.Skip(1).First());
+ Assert.Equal(role3, roleManager.Skip(2).First());
+ Assert.Equal(role4, roleManager.Skip(3).First());
+ Assert.Equal(role0, roleManager.Skip(4).First());
+ Assert.Equal(5, roleManager.Count());
+ Assert.Equal(role1, roleManager.SkipLast(4).Last());
+ Assert.Equal(role2, roleManager.SkipLast(3).Last());
+ Assert.Equal(role3, roleManager.SkipLast(2).Last());
+ Assert.Equal(role4, roleManager.SkipLast(1).Last());
+ Assert.Equal(role0, roleManager.SkipLast(0).Last());
+
+ }
+
+ [Fact]
+ public async Task SortingAffectsEnumeration()
+ {
+
+ UserManager manager = new();
+ UserRoleManager roleManager = manager.RoleManager;
+
+ UserRole defaultRole = roleManager.DefaultRole;
+ UserRole? role1 = await roleManager.TryCreateRole();
+ UserRole? role2 = await roleManager.TryCreateRole();
+ UserRole? role3 = await roleManager.TryCreateRole();
+ UserRole? role4 = await roleManager.TryCreateRole();
+
+ Assert.NotNull(role1);
+ Assert.NotNull(role2);
+ Assert.NotNull(role3);
+ Assert.NotNull(role4);
+
+ UserRole[] array1 = [.. roleManager];
+
+ Assert.Equal(role1, array1[0]);
+ Assert.Equal(role2, array1[1]);
+ Assert.Equal(role3, array1[2]);
+ Assert.Equal(role4, array1[3]);
+ Assert.Equal(defaultRole, array1[4]);
+
+ UserRole[] newSort2 = [role3, role2, role1, role4];
+
+ await roleManager.SetOrder(newSort2.Select(role => role.GUID));
+
+ UserRole[] array2 = [.. roleManager];
+
+ Assert.Equal(newSort2[0], array2[0]);
+ Assert.Equal(newSort2[1], array2[1]);
+ Assert.Equal(newSort2[2], array2[2]);
+ Assert.Equal(newSort2[3], array2[3]);
+ Assert.Equal(defaultRole, array2[4]);
+
+ }
+
+ [Fact]
+ public async Task SortingAffectsEnumerationWithDefaultSpecified()
+ {
+
+ UserManager manager = new();
+ UserRoleManager roleManager = manager.RoleManager;
+
+ UserRole defaultRole = roleManager.DefaultRole;
+ UserRole? role1 = await roleManager.TryCreateRole();
+ UserRole? role2 = await roleManager.TryCreateRole();
+ UserRole? role3 = await roleManager.TryCreateRole();
+ UserRole? role4 = await roleManager.TryCreateRole();
+
+ Assert.NotNull(role1);
+ Assert.NotNull(role2);
+ Assert.NotNull(role3);
+ Assert.NotNull(role4);
+
+ UserRole[] array1 = [.. roleManager];
+
+ Assert.Equal(role1, array1[0]);
+ Assert.Equal(role2, array1[1]);
+ Assert.Equal(role3, array1[2]);
+ Assert.Equal(role4, array1[3]);
+ Assert.Equal(defaultRole, array1[4]);
+
+ UserRole[] newSort2 = [role3, role2, role1, role4, defaultRole];
+
+ await roleManager.SetOrder(newSort2.Select(role => role.GUID));
+
+ UserRole[] array2 = [.. roleManager];
+
+ Assert.Equal(newSort2[0], array2[0]);
+ Assert.Equal(newSort2[1], array2[1]);
+ Assert.Equal(newSort2[2], array2[2]);
+ Assert.Equal(newSort2[3], array2[3]);
+ Assert.Equal(newSort2[4], array2[4]);
+
+ }
+
+}
diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs
index 0bade5e..16c0136 100644
--- a/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs
+++ b/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs
@@ -50,4 +50,14 @@ public void DefaultRoleHasNoDisabledByDefaultPermissions()
}
+ [Fact]
+ public void DefaultRoleIsMarkedAsDefaultRole()
+ {
+
+ UserRole role = UserRole.CreateDefaultRole();
+
+ Assert.True(role.IsDefaultRole);
+
+ }
+
}
diff --git a/PMDDesktop.Server.Users/Roles/IRoleIndexable.cs b/PMDDesktop.Server.Users/Roles/IRoleIndexable.cs
new file mode 100644
index 0000000..3613b50
--- /dev/null
+++ b/PMDDesktop.Server.Users/Roles/IRoleIndexable.cs
@@ -0,0 +1,13 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace PMDDesktop.Server.Users.Roles;
+
+///
+/// Interface for objects that can fetch a from a .
+///
+public interface IRoleIndexable
+{
+
+ bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role);
+
+}
diff --git a/PMDDesktop.Server.Users/Roles/UserRole.cs b/PMDDesktop.Server.Users/Roles/UserRole.cs
index 51cc6dc..5d3f68f 100644
--- a/PMDDesktop.Server.Users/Roles/UserRole.cs
+++ b/PMDDesktop.Server.Users/Roles/UserRole.cs
@@ -9,8 +9,10 @@ public sealed class UserRole
internal UserRole() { }
internal Dictionary permissionSettings = [];
- public string Name {get; set;} = "Unnamed Role";
+ public string Name { get; set; } = "Unnamed Role";
public Guid GUID { get; internal set; } = Guid.NewGuid();
+ public bool IsDefaultRole { get => GUID == Guid.Empty; }
+ public UserRoleManager? Manager {get; private set;}
public async Task SetPermission(UserPermission permission, bool? preference)
{
@@ -48,4 +50,34 @@ internal static UserRole CreateDefaultRole()
}
+ internal void AttachToManager(UserRoleManager newManager)
+ {
+
+ if (Manager != null)
+ throw new InvalidOperationException($"This existing value of {nameof(Manager)} is not null. Cannot attach while already attached. Does this call to {nameof(AttachToManager)} need to be skipped, or does {nameof(DetachFromManager)} need to be called first?");
+
+ Manager = newManager;
+
+ newManager.guids.Add(GUID, this);
+
+ if (!IsDefaultRole)
+ newManager.roleOrder = [.. newManager.roleOrder, GUID];
+
+ }
+
+ internal void DetachFromManager()
+ {
+
+ if (Manager == null)
+ throw new NullReferenceException($"{nameof(Manager)} is null. Cannot detach from nothing. Does this call to {nameof(DetachFromManager)} need to be skipped?");
+
+ if (Manager.guids[GUID] != this)
+ throw new InvalidOperationException($"{this} was not inside {Manager.guids} with key {GUID}.");
+
+ Manager.guids.Remove(GUID);
+
+ Manager = null;
+
+ }
+
}
diff --git a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs
new file mode 100644
index 0000000..4cdd8f5
--- /dev/null
+++ b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs
@@ -0,0 +1,101 @@
+using System.Collections;
+using System.Data;
+using System.Diagnostics.CodeAnalysis;
+
+namespace PMDDesktop.Server.Users.Roles;
+
+public sealed class UserRoleManager : IRoleIndexable, IEnumerable
+{
+
+ internal UserRoleManager(UserManager userManager)
+ {
+
+ Manager = userManager;
+
+ UserRole.CreateDefaultRole().AttachToManager(this);
+
+ }
+
+ public UserManager Manager { get; private init; }
+
+ ///
+ /// This is a dictionary with all the s mapped to their .
+ ///
+ ///
+ /// This dictionary's entries are not managed by the , but instead by the s as they are attached/detached. Do not let write to this dictionary.
+ ///
+ internal Dictionary guids = [];
+
+ ///
+ /// Default applied to all users when checking for s.
+ /// Cannot be added or removed from s.
+ ///
+ internal UserRole DefaultRole { get => guids[Guid.Empty]; }
+
+ ///
+ /// Order to check when evaluating s based on s.
+ /// Should never contain ().
+ ///
+ internal Guid[] roleOrder = [];
+
+ ///
+ /// Attempt to set the order of roles by providing the s of every in this .
+ /// s should be provided in the order of most important (highest) to least important (lowest). s that are higher (provided first) will take priority over lower roles when they have clashing s.
+ /// If provided, () will be ignored.
+ /// Will fail if duplicated GUIDs are provided, or GUIDs that do not correspond to s in this .
+ ///
+ ///
+ /// Task resolves once order is set and changes are saved.
+ public async Task SetOrder(IEnumerable guidOrder)
+ {
+
+ Guid[] orderArray = [.. guidOrder.Where(guid => guid != Guid.Empty)];
+
+ // Each role's GUID should be provided exactly once.
+ foreach (UserRole role in guids.Values.Where(role => !role.IsDefaultRole))
+ if (orderArray.Count(role.GUID) != 1)
+ throw new ArgumentException($"{role}'s GUID ({role.GUID}) was listed {orderArray.Count(role.GUID)} times in the provided order. There needs to be exactly 1 GUID for each role.");
+
+ // All GUIDs should match a role.
+ foreach (Guid guid in orderArray)
+ if (!guids.ContainsKey(guid))
+ throw new ArgumentException($"There is no matching {nameof(UserRole)} in {this} that has GUID {guid}.");
+
+ roleOrder = orderArray;
+
+ return;
+
+ }
+
+ public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role)
+ {
+ return guids.TryGetValue(GUID, out role);
+ }
+
+ public async Task TryCreateRole()
+ {
+
+ UserRole role = new();
+
+ role.AttachToManager(this);
+
+ return role;
+
+ }
+
+ public IEnumerator GetEnumerator()
+ {
+
+ foreach (Guid guid in roleOrder)
+ yield return guids[guid];
+
+ yield return DefaultRole;
+
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return GetEnumerator();
+ }
+
+}
diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs
index a3368fd..ab395ee 100644
--- a/PMDDesktop.Server.Users/UserManager.cs
+++ b/PMDDesktop.Server.Users/UserManager.cs
@@ -1,12 +1,26 @@
-using System.Collections;
+using PMDDesktop.Server.Users.Roles;
+using System.Collections;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
namespace PMDDesktop.Server.Users;
-public sealed class UserManager() : IEnumerable, IUserIndexable
+public sealed class UserManager : IEnumerable, IUserIndexable, IRoleIndexable
{
+ public UserManager()
+ {
+
+ RoleManager = new(this);
+
+ }
+
+ ///
+ /// attached to this
+ /// Used when checking for s and their s.
+ ///
+ public UserRoleManager RoleManager { get; }
+
///
/// Does this actually write to files?
/// If this is false, file writes to the filesystem/disk will be skipped.
@@ -204,4 +218,9 @@ public bool TryGetUserByLoginHandle(string handle, [NotNullWhen(true)] out User?
}
+ public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role)
+ {
+ return RoleManager.TryGetRole(GUID, out role);
+ }
+
}
From 4083fdd89917a9d8b982cc0b380b109dd45ad0ba Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Sun, 27 Sep 2026 23:48:36 -0500
Subject: [PATCH 24/55] Make Users.RoleManager accessable in GameState.Roles
---
PMDDesktop.Server/Game/GameState.cs | 18 +++++++++++++++---
1 file changed, 15 insertions(+), 3 deletions(-)
diff --git a/PMDDesktop.Server/Game/GameState.cs b/PMDDesktop.Server/Game/GameState.cs
index 5212ecb..1aa0c65 100644
--- a/PMDDesktop.Server/Game/GameState.cs
+++ b/PMDDesktop.Server/Game/GameState.cs
@@ -1,6 +1,7 @@
using PMDDesktop.Server.Assets;
using PMDDesktop.Server.Saving;
using PMDDesktop.Server.Users;
+using PMDDesktop.Server.Users.Roles;
using System.Diagnostics.CodeAnalysis;
namespace PMDDesktop.Server.Game;
@@ -8,6 +9,16 @@ namespace PMDDesktop.Server.Game;
public class GameState : IAssetIndexable, ISaveDataIndexable, IUserIndexable
{
+ public GameState()
+ {
+
+ Saves = [];
+ Assets = new();
+ Users = new();
+ Roles = Users.RoleManager;
+
+ }
+
[ExcludeFromCodeCoverage]
public static async Task CreateAndLoadFiles()
{
@@ -55,8 +66,9 @@ public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user)
return Users.TryGetUser(GUID, out user);
}
- public SaveDataManager Saves { get; } = new();
- public AssetManager Assets { get; } = new();
- public UserManager Users { get; } = new();
+ public SaveDataManager Saves { get; }
+ public AssetManager Assets { get; }
+ public UserManager Users { get; }
+ public UserRoleManager Roles { get; }
}
From c1ede699b8e286ffb72d6ceb2288e644d859c4ba Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Thu, 1 Oct 2026 23:21:21 -0500
Subject: [PATCH 25/55] Add Role saving
---
PMDDesktop.Server.Users/Roles/UserRole.cs | 97 ++++++++++++-
.../Roles/UserRoleManager.cs | 130 ++++++++++++++++++
PMDDesktop.Server.Users/UserManager.cs | 14 +-
3 files changed, 226 insertions(+), 15 deletions(-)
diff --git a/PMDDesktop.Server.Users/Roles/UserRole.cs b/PMDDesktop.Server.Users/Roles/UserRole.cs
index 5d3f68f..26c2ee0 100644
--- a/PMDDesktop.Server.Users/Roles/UserRole.cs
+++ b/PMDDesktop.Server.Users/Roles/UserRole.cs
@@ -1,4 +1,6 @@
-using System.Text.Json.Serialization;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using System.Text.Json.Serialization;
namespace PMDDesktop.Server.Users.Roles;
@@ -9,10 +11,20 @@ public sealed class UserRole
internal UserRole() { }
internal Dictionary permissionSettings = [];
- public string Name { get; set; } = "Unnamed Role";
+ public string Name { get; internal set; } = "Unnamed Role";
public Guid GUID { get; internal set; } = Guid.NewGuid();
public bool IsDefaultRole { get => GUID == Guid.Empty; }
- public UserRoleManager? Manager {get; private set;}
+ public UserRoleManager? Manager { get; private set; }
+ public bool? WritingEnabled { get; private set; }
+
+ public async Task SetName(string newName)
+ {
+
+ Name = newName;
+
+ await WriteNewData();
+
+ }
public async Task SetPermission(UserPermission permission, bool? preference)
{
@@ -22,6 +34,9 @@ public async Task SetPermission(UserPermission permission, bool? preference)
else
permissionSettings.Remove(permission);
+ if (WritingEnabled is true)
+ await WriteNewData();
+
}
public bool? GetPermission(UserPermission permission)
@@ -57,6 +72,7 @@ internal void AttachToManager(UserRoleManager newManager)
throw new InvalidOperationException($"This existing value of {nameof(Manager)} is not null. Cannot attach while already attached. Does this call to {nameof(AttachToManager)} need to be skipped, or does {nameof(DetachFromManager)} need to be called first?");
Manager = newManager;
+ WritingEnabled = Manager.WritingEnabled;
newManager.guids.Add(GUID, this);
@@ -76,8 +92,83 @@ internal void DetachFromManager()
Manager.guids.Remove(GUID);
+ if (!IsDefaultRole)
+ Manager.roleOrder = [.. Manager.roleOrder.Where(guid => guid != GUID)];
+
Manager = null;
}
+ ///
+ /// Gets the primary JSON that contains this 's data, located inside the folder.
+ ///
+ /// A path pointing to where the 's json data is/should be stored.
+ internal string GetRoleJSONPath()
+ {
+
+ return Path.Combine(AppContext.BaseDirectory, "roles", $"{GUID}.json");
+
+ }
+
+ [ExcludeFromCodeCoverage]
+ internal async Task WriteNewData()
+ {
+
+ if (Manager is null) // ????? This state doesn't make any sense.
+ throw new InvalidOperationException($"{this} shouldn't be writing its data to a file while {nameof(Manager)} is null!");
+
+ if (WritingEnabled is null)
+ throw new InvalidOperationException($"{this} shouldn't be writing its data to a file while {nameof(WritingEnabled)} is null!");
+
+ if (WritingEnabled != true)
+ return;
+
+ using FileStream jsonFile = File.Create(GetRoleJSONPath());
+
+ await JsonSerializer.SerializeAsync(jsonFile, this, AppInfo.JSON_OPTIONS);
+
+ }
+
+ ///
+ /// Deletes the folder and all *explicitly written* data inside it.
+ ///
+ ///
+ ///
+ /// This function does not recursive delete. Instead it has a list of items to delete. This approach is taken to minimize data loss in the event that something goes wrong.
+ ///
+ [ExcludeFromCodeCoverage]
+ private async Task DeleteRoleFolder()
+ {
+
+ if (WritingEnabled is null)
+ throw new InvalidOperationException($"{this} shouldn't be deleting its data from a file while {nameof(WritingEnabled)} is null!");
+
+ if (WritingEnabled != true)
+ return;
+
+ File.Delete(GetRoleJSONPath());
+
+ }
+
+ public async Task TryRemoveRole()
+ {
+
+ if (Manager is null)
+ return false;
+
+ if (IsDefaultRole)
+ return false; // Default role cannot be removed manually.
+
+ UserRoleManager oldManager = Manager;
+
+ DetachFromManager();
+
+ await DeleteRoleFolder();
+
+ await oldManager.WriteNewRoleOrder();
+
+ return true;
+
+ }
+
}
diff --git a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs
index 4cdd8f5..909694c 100644
--- a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs
+++ b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs
@@ -1,6 +1,7 @@
using System.Collections;
using System.Data;
using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
namespace PMDDesktop.Server.Users.Roles;
@@ -18,6 +19,16 @@ internal UserRoleManager(UserManager userManager)
public UserManager Manager { get; private init; }
+ ///
+ /// Does this actually write to files?
+ /// If this is false, file writes to the filesystem/disk will be skipped.
+ /// This property checks to get its value.
+ ///
+ ///
+ /// This is property as "false" is very useful in unit testing. Should probably be "true" during runtime.
+ ///
+ public bool WritingEnabled { get => Manager.WritingEnabled; }
+
///
/// This is a dictionary with all the s mapped to their .
///
@@ -38,6 +49,103 @@ internal UserRoleManager(UserManager userManager)
///
internal Guid[] roleOrder = [];
+ internal async Task LoadFromFiles()
+ {
+
+ await LoadAllRoleData();
+
+ await LoadRoleOrder();
+
+ }
+
+ ///
+ /// This manages creating objects by loading their data from the "roles" folder.
+ ///
+ [ExcludeFromCodeCoverage]
+ internal async Task LoadAllRoleData()
+ {
+
+ string roleRootDirPath = Path.Combine(AppContext.BaseDirectory, "roles");
+
+ if (!Directory.Exists(roleRootDirPath))
+ Directory.CreateDirectory(roleRootDirPath);
+
+ foreach (string filePath in Directory.EnumerateFiles(roleRootDirPath))
+ {
+
+ string guidParsable = Path.GetFileNameWithoutExtension(filePath);
+
+ if (!Guid.TryParse(guidParsable, out Guid loadedGUID))
+ throw new Exception($"Couldn't parse {guidParsable} as a GUID at {filePath}");
+
+ string userFilePath = Path.Join(filePath, User.USER_FILE_NAME);
+
+ using FileStream readStream = File.OpenRead(userFilePath);
+
+ await LoadAndAddRoleJson(readStream, loadedGUID);
+
+ }
+
+ }
+
+ ///
+ /// Internal function for loading a JSON stream and adding the resulting to this .
+ ///
+ /// A JSON stream with data inside.
+ ///
+ ///
+ /// This can be safely unit tested, as it takes in a stream instead of reading from a file.
+ ///
+ internal async Task LoadAndAddRoleJson(Stream stream, Guid guid)
+ {
+
+ UserRole deserialized = JsonSerializer.Deserialize(stream, AppInfo.JSON_OPTIONS)
+ ?? throw new Exception($"Deserialized user role data from {stream} was null.");
+
+ deserialized.GUID = guid; // GUID will be randomized by default, we need to load the previous GUID.
+
+ deserialized.AttachToManager(this);
+
+ return deserialized;
+
+ }
+
+ ///
+ /// Gets the primary JSON that contains this 's data, located inside the folder.
+ ///
+ /// A path pointing to where the 's json data is/should be stored.
+ internal static string GetOrderJSONPath()
+ {
+
+ return Path.Combine(AppContext.BaseDirectory, "roleOrder.json");
+
+ }
+
+ [ExcludeFromCodeCoverage]
+ private async Task LoadRoleOrder()
+ {
+
+ string path = GetOrderJSONPath();
+
+ if (!File.Exists(path))
+ return; // No biggie! Just assume default order.
+
+ using Stream stream = File.OpenRead(path);
+
+ await LoadRoleOrder(stream);
+
+ }
+
+ internal async Task LoadRoleOrder(Stream stream)
+ {
+
+ Guid[] order = await JsonSerializer.DeserializeAsync(stream, AppInfo.JSON_OPTIONS)
+ ?? throw new Exception($"Deserialized user role order from {stream} was null.");
+
+ await SetOrder(order);
+
+ }
+
///
/// Attempt to set the order of roles by providing the s of every in this .
/// s should be provided in the order of most important (highest) to least important (lowest). s that are higher (provided first) will take priority over lower roles when they have clashing s.
@@ -63,10 +171,28 @@ public async Task SetOrder(IEnumerable guidOrder)
roleOrder = orderArray;
+ if (WritingEnabled)
+ await WriteNewRoleOrder();
+
return;
}
+ public async Task WriteNewRoleOrder()
+ {
+
+ if (Manager is null) // ????? This state doesn't make any sense.
+ throw new InvalidOperationException($"{this} shouldn't be writing its data to a file while {nameof(Manager)} is null!");
+
+ if (!WritingEnabled)
+ return;
+
+ using FileStream jsonFile = File.Create(GetOrderJSONPath());
+
+ await JsonSerializer.SerializeAsync(jsonFile, roleOrder, AppInfo.JSON_OPTIONS);
+
+ }
+
public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role)
{
return guids.TryGetValue(GUID, out role);
@@ -79,6 +205,10 @@ public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role)
role.AttachToManager(this);
+ await role.WriteNewData();
+
+ await WriteNewRoleOrder();
+
return role;
}
diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs
index ab395ee..62c767e 100644
--- a/PMDDesktop.Server.Users/UserManager.cs
+++ b/PMDDesktop.Server.Users/UserManager.cs
@@ -69,7 +69,7 @@ public async Task LoadFromFilesAndEnableWriting()
await LoadAllUserData();
- await LoadAllRoleData();
+ await RoleManager.LoadFromFiles();
}
@@ -126,17 +126,6 @@ internal async Task LoadAndAddUserJson(Stream stream, Guid guid)
}
- ///
- /// This manages creating objects by loading their data from the "users" folder.
- ///
- [ExcludeFromCodeCoverage]
- private async Task LoadAllRoleData()
- {
-
- // Not yet implemented.
-
- }
-
public IEnumerator GetEnumerator()
{
return guids.Values.GetEnumerator();
@@ -214,6 +203,7 @@ public bool TryGetUserByLoginHandle(string handle, [NotNullWhen(true)] out User?
await user.SetPassword(options.PlainTextPassword);
user.AttachToManager(this);
+ await user.WriteNewData();
return user;
}
From 686b7c622845dca8a309b2f2280ed084200e200c Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Fri, 2 Oct 2026 12:03:24 -0500
Subject: [PATCH 26/55] fix confusing exceptions in User
---
PMDDesktop.Server.Users/User.cs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index 03b2e51..f3ed766 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -205,10 +205,10 @@ internal async Task WriteNewData()
{
if (Manager is null) // ????? This state doesn't make any sense.
- throw new InvalidOperationException($"{this} shouldn't be writing its data without being attached to a {nameof(Manager)}.");
+ throw new InvalidOperationException($"{this} shouldn't be writing its data to a file while {nameof(Manager)} is null!");
if (WritingEnabled is null)
- throw new InvalidOperationException($"{this} shouldn't be deleting its data while {WritingEnabled} is null!");
+ throw new InvalidOperationException($"{this} shouldn't be writing its data to a file while {nameof(WritingEnabled)} is null!");
foreach (UserAccessToken token in accessTokens)
if (DateTime.UtcNow >= token.ExpiryDate)
@@ -235,7 +235,7 @@ private async Task DeleteUserFolder()
{
if (WritingEnabled is null)
- throw new InvalidOperationException($"{this} shouldn't be deleting its data while {WritingEnabled} is null!");
+ throw new InvalidOperationException($"{this} shouldn't be deleting its data from the filesystem while {nameof(WritingEnabled)} is null!");
if (WritingEnabled != true)
return;
From 7af959c87267e16b1578a2a8e74994f302837a09 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Fri, 2 Oct 2026 12:33:52 -0500
Subject: [PATCH 27/55] Add regions for functions in User
This file is getting really big!! This helps it be more managable. I'll try to region stuff better in the future.
---
PMDDesktop.Server.Users/User.cs | 28 ++++++++++++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index f3ed766..05e8558 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -74,6 +74,8 @@ internal User()
internal List accessTokens = [];
+ #region Name
+
public async Task SetName(string newName)
{
@@ -84,6 +86,10 @@ public async Task SetName(string newName)
}
+ #endregion
+
+ #region Login Handle & Password
+
///
/// Salt and hash the user's password, then store it in HashedPassword.
///
@@ -177,6 +183,10 @@ public async Task VerifyPassword(string plainText)
}
+ #endregion
+
+ #region Saving
+
///
/// Return the filesystem folder that should contain all of this user's data.
///
@@ -257,6 +267,10 @@ private async Task DeleteUserFolder()
}
+ #endregion
+
+ #region Generic Object Overrides
+
public override bool Equals(object? obj)
{
@@ -276,6 +290,10 @@ public override int GetHashCode()
return HashCode.Combine(GUID);
}
+ #endregion
+
+ #region Access Tokens
+
public async Task CreateAccessToken()
{
@@ -347,6 +365,10 @@ private async Task TryRevokeOldestAccessToken(bool writeAfterwards = true)
}
+ #endregion
+
+ #region Manager Attaching
+
internal void AttachToManager(UserManager manager)
{
@@ -387,6 +409,10 @@ internal void DetachFromManager()
public bool IsAlive() => Manager is not null;
+ #endregion
+
+ #region User Deletion
+
///
/// Try to remove this from its .
/// If the assigned has , then this function will also attempt to delete the user's folder from the filesystem, so that it will not be restored in future sessions.
@@ -408,4 +434,6 @@ public async Task TryRemoveUser()
}
+ #endregion
+
}
From 773745146dc97d7f710955766d8a3fc079482fef Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Sat, 3 Oct 2026 21:41:26 -0500
Subject: [PATCH 28/55] Add RoleReference and Tests
---
.../Roles/RoleReferenceTests.cs | 51 +++++++++++++++++++
.../Roles/RoleReference.cs | 34 +++++++++++++
.../Roles/RoleReferenceConverter.cs | 29 +++++++++++
3 files changed, 114 insertions(+)
create mode 100644 PMDDesktop.Server.Users.Tests/Roles/RoleReferenceTests.cs
create mode 100644 PMDDesktop.Server.Users/Roles/RoleReference.cs
create mode 100644 PMDDesktop.Server.Users/Roles/RoleReferenceConverter.cs
diff --git a/PMDDesktop.Server.Users.Tests/Roles/RoleReferenceTests.cs b/PMDDesktop.Server.Users.Tests/Roles/RoleReferenceTests.cs
new file mode 100644
index 0000000..29d8e62
--- /dev/null
+++ b/PMDDesktop.Server.Users.Tests/Roles/RoleReferenceTests.cs
@@ -0,0 +1,51 @@
+using PMDDesktop.Server.Users.Roles;
+using System.Text.Json;
+
+namespace PMDDesktop.Server.Users.Tests.Roles;
+
+public class RoleReferenceTests
+{
+
+ [Fact]
+ public async Task CanGetReference()
+ {
+
+ UserManager manager = new();
+ UserRoleManager roleManager = manager.RoleManager;
+ UserRole? role = await roleManager.TryCreateRole();
+ Assert.NotNull(role);
+
+ RoleReference reference = new(role);
+
+ Assert.True(reference.TryGetRole(manager, out UserRole? referRole));
+
+ Assert.Equal(role, referRole);
+
+ }
+
+ [Fact]
+ public async Task SerializationIsSimple()
+ {
+
+ UserManager manager = new();
+ UserRoleManager roleManager = manager.RoleManager;
+ UserRole? role = await roleManager.TryCreateRole();
+ Assert.NotNull(role);
+
+ RoleReference preReference = new(role);
+
+ JsonElement json = JsonSerializer.SerializeToElement(preReference, AppInfo.JSON_OPTIONS);
+
+ Assert.Equal(JsonValueKind.String, json.ValueKind);
+
+ RoleReference? postReference = JsonSerializer.Deserialize(json);
+
+ Assert.NotNull(postReference);
+
+ Assert.True(postReference.TryGetRole(manager, out UserRole? referRole));
+
+ Assert.Equal(role, referRole);
+
+ }
+
+}
diff --git a/PMDDesktop.Server.Users/Roles/RoleReference.cs b/PMDDesktop.Server.Users/Roles/RoleReference.cs
new file mode 100644
index 0000000..da0235f
--- /dev/null
+++ b/PMDDesktop.Server.Users/Roles/RoleReference.cs
@@ -0,0 +1,34 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+
+namespace PMDDesktop.Server.Users.Roles;
+
+[JsonConverter(typeof(RoleReferenceConverter))]
+public sealed class RoleReference
+{
+
+ public Guid GUID { get; private set; }
+
+
+ public RoleReference(UserRole initalValue)
+ {
+
+ GUID = initalValue.GUID;
+
+ }
+
+ internal RoleReference(Guid directGuid)
+ {
+
+ GUID = directGuid;
+
+ }
+
+ public bool TryGetRole(IRoleIndexable indexable, [NotNullWhen(true)] out UserRole? role)
+ {
+
+ return indexable.TryGetRole(GUID, out role);
+
+ }
+
+}
diff --git a/PMDDesktop.Server.Users/Roles/RoleReferenceConverter.cs b/PMDDesktop.Server.Users/Roles/RoleReferenceConverter.cs
new file mode 100644
index 0000000..f36b0a5
--- /dev/null
+++ b/PMDDesktop.Server.Users/Roles/RoleReferenceConverter.cs
@@ -0,0 +1,29 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace PMDDesktop.Server.Users.Roles;
+
+
+internal class RoleReferenceConverter : JsonConverter
+{
+ public override RoleReference Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+
+ if (reader.TokenType != JsonTokenType.String)
+ throw new JsonException($"{Type} was expecting a string token to deserialize.");
+
+ string guidString = reader.GetString() ?? throw new JsonException($"We read a null string while trying to deserialize {Type}.");
+
+ Guid guid = Guid.Parse(guidString);
+
+ return new RoleReference(guid);
+
+ }
+
+ public override void Write(Utf8JsonWriter writer, RoleReference value, JsonSerializerOptions options)
+ {
+
+ writer.WriteStringValue(value.GUID.ToString());
+
+ }
+}
From 477299961413dedc2f70ead02c23008d8f35e4ff Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Sun, 4 Oct 2026 20:53:05 -0500
Subject: [PATCH 29/55] add role adding in User and tests
---
PMDDesktop.Server.Users.Tests/UserTests.cs | 68 +++++++++++++++++++++-
PMDDesktop.Server.Users/User.cs | 65 +++++++++++++++++++++
2 files changed, 132 insertions(+), 1 deletion(-)
diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs
index b865828..87f0341 100644
--- a/PMDDesktop.Server.Users.Tests/UserTests.cs
+++ b/PMDDesktop.Server.Users.Tests/UserTests.cs
@@ -1,4 +1,6 @@
-namespace PMDDesktop.Server.Users.Tests;
+using PMDDesktop.Server.Users.Roles;
+
+namespace PMDDesktop.Server.Users.Tests;
public class UserTests
{
@@ -175,4 +177,68 @@ public async Task DeleteUserTest()
}
+ // I'm not sure if there's a better place for these role/permission/user tests. They test way more than just the User.cs file, but they start and end there.
+ [Fact]
+ public async Task UserGetsPermission()
+ {
+
+ UserManager manager = new();
+ User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
+ Assert.NotNull(user);
+
+ UserRoleManager roleManager = manager.RoleManager;
+ UserRole? role = await roleManager.TryCreateRole();
+ Assert.NotNull(role);
+ await role.SetPermission(UserPermission.MANAGE_ROLES, true);
+
+ Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES));
+
+ Assert.True(await user.TryAddRole(role));
+
+ Assert.True(user.HasPermission(UserPermission.MANAGE_ROLES));
+
+ Assert.True(await user.TryRemoveRole(role));
+
+ Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES));
+
+ }
+
+ [Fact]
+ public async Task UserPermissionsRespectHierarchy()
+ {
+
+ UserManager manager = new();
+ User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
+ Assert.NotNull(user);
+
+ UserRoleManager roleManager = manager.RoleManager;
+ UserRole? givingRole = await roleManager.TryCreateRole();
+ Assert.NotNull(givingRole);
+ await givingRole.SetPermission(UserPermission.MANAGE_ROLES, true);
+ await user.TryAddRole(givingRole);
+
+ UserRole? takingRole = await roleManager.TryCreateRole();
+ Assert.NotNull(takingRole);
+ await takingRole.SetPermission(UserPermission.MANAGE_ROLES, false);
+ await user.TryAddRole(takingRole);
+
+ UserRole? nothingRole = await roleManager.TryCreateRole();
+ Assert.NotNull(nothingRole);
+ await nothingRole.SetPermission(UserPermission.MANAGE_ROLES, null);
+ await user.TryAddRole(nothingRole);
+
+ await roleManager.SetOrder([givingRole.GUID, takingRole.GUID, nothingRole.GUID]);
+ Assert.True(user.HasPermission(UserPermission.MANAGE_ROLES));
+
+ await roleManager.SetOrder([takingRole.GUID, givingRole.GUID, nothingRole.GUID]);
+ Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES));
+
+ await roleManager.SetOrder([nothingRole.GUID, givingRole.GUID, takingRole.GUID]);
+ Assert.True(user.HasPermission(UserPermission.MANAGE_ROLES));
+
+ await roleManager.SetOrder([nothingRole.GUID, takingRole.GUID, givingRole.GUID]);
+ Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES));
+
+ }
+
}
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index 05e8558..dd23f3d 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Identity;
+using PMDDesktop.Server.Users.Roles;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -74,6 +75,12 @@ internal User()
internal List accessTokens = [];
+ ///
+ /// Collection of s assigned to this user by their s. Order does not matter, use for order instead.
+ /// Also, is not included in this. It is implied to always be assigned.
+ ///
+ internal HashSet roles = [];
+
#region Name
public async Task SetName(string newName)
@@ -436,4 +443,62 @@ public async Task TryRemoveUser()
#endregion
+ #region Role/Permission Management
+
+ public async Task TryAddRole(UserRole role)
+ {
+
+ if (!roles.Add(role.GUID))
+ return false;
+
+ await WriteNewData();
+
+ return true;
+
+ }
+
+ public async Task TryRemoveRole(UserRole role)
+ {
+
+ if (!roles.Remove(role.GUID))
+ return false;
+
+ await WriteNewData();
+
+ return true;
+
+ }
+
+ public bool HasRole(UserRole role)
+ {
+
+ return roles.Contains(role.GUID);
+
+ }
+
+ public bool HasPermission(UserPermission permission)
+ {
+
+ if (Manager is null)
+ throw new NullReferenceException($"Can't check if {this} has a permission if {nameof(Manager)} is null!");
+
+ foreach (UserRole role in Manager.RoleManager)
+ {
+
+ if (!HasRole(role))
+ continue;
+
+ bool? newState = role.GetPermission(permission);
+
+ if (newState is bool final)
+ return final;
+
+ }
+
+ return false;
+
+ }
+
+ #endregion
+
}
From 0629f475c7b5688224488a350225b17806e576b4 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Sun, 4 Oct 2026 20:59:20 -0500
Subject: [PATCH 30/55] Add test for changing user names.
---
PMDDesktop.Server.Users.Tests/UserTests.cs | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs
index 87f0341..7ec9ff5 100644
--- a/PMDDesktop.Server.Users.Tests/UserTests.cs
+++ b/PMDDesktop.Server.Users.Tests/UserTests.cs
@@ -177,6 +177,24 @@ public async Task DeleteUserTest()
}
+ [Fact]
+ public async Task CanChangeName()
+ {
+
+ UserManager manager = new();
+ User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
+ Assert.NotNull(user);
+
+ string originalName = user.Name;
+
+ await user.SetName("Waffles");
+
+ Assert.Equal("Waffles", user.Name);
+
+ Assert.NotEqual(originalName, user.Name);
+
+ }
+
// I'm not sure if there's a better place for these role/permission/user tests. They test way more than just the User.cs file, but they start and end there.
[Fact]
public async Task UserGetsPermission()
From 90388bad766e7305393414ad41b08817110396a2 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Mon, 5 Oct 2026 14:05:13 -0500
Subject: [PATCH 31/55] remove check for null non-nullable Manager
This happened in WriteNewRoleOrder() because of a copy-pase oversight. Whoops!
---
PMDDesktop.Server.Users/Roles/UserRoleManager.cs | 3 ---
1 file changed, 3 deletions(-)
diff --git a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs
index 909694c..b97568a 100644
--- a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs
+++ b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs
@@ -181,9 +181,6 @@ public async Task SetOrder(IEnumerable guidOrder)
public async Task WriteNewRoleOrder()
{
- if (Manager is null) // ????? This state doesn't make any sense.
- throw new InvalidOperationException($"{this} shouldn't be writing its data to a file while {nameof(Manager)} is null!");
-
if (!WritingEnabled)
return;
From ad67cd26b46308758e97be8017ca84f39e721105 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Mon, 5 Oct 2026 22:11:11 -0500
Subject: [PATCH 32/55] Split RevokeAccessToken into an internal and public
function
---
PMDDesktop.Server.Users/User.cs | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index dd23f3d..79e3f02 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -330,7 +330,7 @@ private void AttachAccessToken(UserAccessToken token)
}
- public async Task RevokeAccessToken(UserAccessToken token, bool writeAfterwards = true)
+ internal async Task RevokeAccessToken(UserAccessToken token, bool writeAfterwards)
{
accessTokens.Remove(token);
@@ -342,6 +342,13 @@ public async Task RevokeAccessToken(UserAccessToken token, bool writeAfterwards
}
+ public async Task RevokeAccessToken(UserAccessToken token)
+ {
+
+ await RevokeAccessToken(token, true);
+
+ }
+
private void DetachAccessToken(UserAccessToken token)
{
From ddce3bb23da04680274efc3b6a50581682b9678c Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Mon, 5 Oct 2026 22:54:24 -0500
Subject: [PATCH 33/55] Document everything in Server.Users
---
PMDDesktop.Server.Users/IUserIndexable.cs | 6 +
.../Roles/IRoleIndexable.cs | 6 +
.../Roles/RoleReference.cs | 20 ++-
.../Roles/UserPermission.cs | 18 ++-
PMDDesktop.Server.Users/Roles/UserRole.cs | 74 +++++++++++
.../Roles/UserRoleConverter.cs | 27 ++++
.../Roles/UserRoleManager.cs | 40 +++++-
PMDDesktop.Server.Users/User.cs | 120 ++++++++++++++++--
PMDDesktop.Server.Users/UserAccessToken.cs | 46 +++++++
PMDDesktop.Server.Users/UserConverter.cs | 27 ++++
.../UserCreationOptions.cs | 19 ++-
PMDDesktop.Server.Users/UserManager.cs | 12 ++
PMDDesktop.Server.Users/UserReference.cs | 22 +++-
13 files changed, 412 insertions(+), 25 deletions(-)
diff --git a/PMDDesktop.Server.Users/IUserIndexable.cs b/PMDDesktop.Server.Users/IUserIndexable.cs
index 78474b5..ad4f65c 100644
--- a/PMDDesktop.Server.Users/IUserIndexable.cs
+++ b/PMDDesktop.Server.Users/IUserIndexable.cs
@@ -8,6 +8,12 @@ namespace PMDDesktop.Server.Users;
public interface IUserIndexable
{
+ ///
+ /// Try to get a by their . Will return false and will be null if not found.
+ ///
+ /// The of the you're looking for.
+ /// The found , if any was found. Will be null if the return was false.
+ /// True if the is found, and will not be null.
bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user);
}
diff --git a/PMDDesktop.Server.Users/Roles/IRoleIndexable.cs b/PMDDesktop.Server.Users/Roles/IRoleIndexable.cs
index 3613b50..a487434 100644
--- a/PMDDesktop.Server.Users/Roles/IRoleIndexable.cs
+++ b/PMDDesktop.Server.Users/Roles/IRoleIndexable.cs
@@ -8,6 +8,12 @@ namespace PMDDesktop.Server.Users.Roles;
public interface IRoleIndexable
{
+ ///
+ /// Try to get a by its . Will return false and will be null if not found.
+ ///
+ /// The of the you're looking for.
+ /// The found , if any was found. Will be null if the return was false.
+ /// True if the is found, and will not be null.
bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role);
}
diff --git a/PMDDesktop.Server.Users/Roles/RoleReference.cs b/PMDDesktop.Server.Users/Roles/RoleReference.cs
index da0235f..677d98c 100644
--- a/PMDDesktop.Server.Users/Roles/RoleReference.cs
+++ b/PMDDesktop.Server.Users/Roles/RoleReference.cs
@@ -7,9 +7,16 @@ namespace PMDDesktop.Server.Users.Roles;
public sealed class RoleReference
{
+ ///
+ /// Stored corresponding to .
+ ///
public Guid GUID { get; private set; }
-
+ ///
+ /// Create a new pointing to 's .
+ /// This allows you to find the again after serialization and deserialization.
+ ///
+ /// The you'd like to keep a reference to via .
public RoleReference(UserRole initalValue)
{
@@ -17,6 +24,11 @@ public RoleReference(UserRole initalValue)
}
+ ///
+ /// Create a new pointing exactly to
+ /// The existance of any with this is not checked until is called.
+ ///
+ /// The you'd like to point to.
internal RoleReference(Guid directGuid)
{
@@ -24,6 +36,12 @@ internal RoleReference(Guid directGuid)
}
+ ///
+ /// Try to get a from its .
+ ///
+ /// The to use to find .
+ /// The result of the search. Will be null if false is returned, and contain a if true.
+ /// True if the was found and has a value, otherwise false.
public bool TryGetRole(IRoleIndexable indexable, [NotNullWhen(true)] out UserRole? role)
{
diff --git a/PMDDesktop.Server.Users/Roles/UserPermission.cs b/PMDDesktop.Server.Users/Roles/UserPermission.cs
index 0c8519e..83d9bb8 100644
--- a/PMDDesktop.Server.Users/Roles/UserPermission.cs
+++ b/PMDDesktop.Server.Users/Roles/UserPermission.cs
@@ -14,13 +14,16 @@ static UserPermission()
IEnumerable permissionFields = fields.Where(field => field.FieldType.IsAssignableTo(typeof(UserPermission)));
IEnumerable permissionObjects = permissionFields.Select(field =>
- (UserPermission?) field.GetValue(ALL)
+ (UserPermission?)field.GetValue(ALL)
?? throw new NullReferenceException($"Unable to convert {field} to {nameof(UserPermission)}"));
ALL = ImmutableArray.Create([.. permissionObjects]);
}
+ ///
+ /// Array containing all objects. (Should not change after initial creation.)
+ ///
public static readonly ImmutableArray ALL;
public static readonly UserPermission MANAGE_USERS = new()
@@ -44,24 +47,27 @@ static UserPermission()
///
/// Name that is stored and compared to track permission in a .
///
- public required string DataName {get; init;}
+ public required string DataName { get; init; }
///
/// User-facing name that is displayed to users when viewing permissions.
///
- public required string FriendlyName {get; init;}
+ public required string FriendlyName { get; init; }
///
/// User-facing description for what a permission does.
///
- public required string FriendlyDescription {get; init;}
+ public required string FriendlyDescription { get; init; }
///
/// Weather to always grant this permission for admin users, to prevent losing ability to regain the permission.
///
- public required bool AlwaysForAdmin {get; init;}
+ public required bool AlwaysForAdmin { get; init; }
- public required bool EnabledForDefault {get; init;}
+ ///
+ /// Weather to enable this permission for the default role when first created. (Can still be disabled for that role afterwards.)
+ ///
+ public required bool EnabledForDefault { get; init; }
private UserPermission() { }
diff --git a/PMDDesktop.Server.Users/Roles/UserRole.cs b/PMDDesktop.Server.Users/Roles/UserRole.cs
index 26c2ee0..6333e77 100644
--- a/PMDDesktop.Server.Users/Roles/UserRole.cs
+++ b/PMDDesktop.Server.Users/Roles/UserRole.cs
@@ -10,13 +10,47 @@ public sealed class UserRole
internal UserRole() { }
+ ///
+ /// A containing granted/revoked s.
+ /// An entry is true if this grants that , and false if it revokes the .
+ /// A non-existant entry states that the is unaffected.
+ ///
internal Dictionary permissionSettings = [];
+
+ ///
+ /// A string representing a 's name.
+ ///
public string Name { get; internal set; } = "Unnamed Role";
+
+ ///
+ /// A unique for this .
+ /// This should be used for long-term references to a specific .
+ ///
public Guid GUID { get; internal set; } = Guid.NewGuid();
+
+ ///
+ /// Weather this is the "default role."
+ ///
+ ///
+ /// Currently just checks if matches .
+ ///
public bool IsDefaultRole { get => GUID == Guid.Empty; }
+
+ ///
+ /// Attached that controls this .
+ ///
public UserRoleManager? Manager { get; private set; }
+
+ ///
+ /// Is/Was on the last used or currently active .
+ ///
public bool? WritingEnabled { get; private set; }
+ ///
+ /// Set the of this .
+ ///
+ /// The new name of this .
+ /// Completes the task once the has saved.
public async Task SetName(string newName)
{
@@ -26,6 +60,13 @@ public async Task SetName(string newName)
}
+ ///
+ /// Set the state of in this .
+ /// Pass true to permit , false to revoke it, and null to not affect it.
+ ///
+ /// The to modify.
+ /// True if permission should be granted, False is permission should be revoked, null if permission should be unaffected.
+ /// Completes the task once the has saved.
public async Task SetPermission(UserPermission permission, bool? preference)
{
@@ -39,6 +80,11 @@ public async Task SetPermission(UserPermission permission, bool? preference)
}
+ ///
+ /// Get the state of in this .
+ ///
+ /// The to check.
+ /// True if permission is granted, False is permission is revoked, null if permission is unaffected.
public bool? GetPermission(UserPermission permission)
{
@@ -49,6 +95,10 @@ public async Task SetPermission(UserPermission permission, bool? preference)
}
+ ///
+ /// Create a new with and the s marked as .
+ ///
+ /// A new configured to be the default role.
internal static UserRole CreateDefaultRole()
{
@@ -65,6 +115,11 @@ internal static UserRole CreateDefaultRole()
}
+ ///
+ /// Attach to a to provide this 's data to the .
+ ///
+ /// The to attach to.
+ /// Throws if already attached to a .
internal void AttachToManager(UserRoleManager newManager)
{
@@ -81,6 +136,13 @@ internal void AttachToManager(UserRoleManager newManager)
}
+ ///
+ /// Detach from the attached to remove this 's data from the attached .
+ ///
+ /// Throws if not currently attached to a .
+ ///
+ /// This doesn't really need to be called on server shutdown. It is called internally when deleting a .
+ ///
internal void DetachFromManager()
{
@@ -110,6 +172,11 @@ internal string GetRoleJSONPath()
}
+ ///
+ /// Saves all data relating to this object.
+ ///
+ /// Task completes once data is written.
+ /// If the or if is null.
[ExcludeFromCodeCoverage]
internal async Task WriteNewData()
{
@@ -150,6 +217,13 @@ private async Task DeleteRoleFolder()
}
+ ///
+ /// Try to remove this from its .
+ /// If the assigned has , then this function will also attempt to delete the role's file from the filesystem, so that it will not be restored in future sessions.
+ ///
+ ///
+ /// If is enabled, this function may still throw if an IO-related error occurs.
+ ///
public async Task TryRemoveRole()
{
diff --git a/PMDDesktop.Server.Users/Roles/UserRoleConverter.cs b/PMDDesktop.Server.Users/Roles/UserRoleConverter.cs
index e42d2c7..76ad4ab 100644
--- a/PMDDesktop.Server.Users/Roles/UserRoleConverter.cs
+++ b/PMDDesktop.Server.Users/Roles/UserRoleConverter.cs
@@ -6,7 +6,14 @@ namespace PMDDesktop.Server.Users.Roles;
public class UserRoleConverter : JsonConverter
{
+ ///
+ /// The property that the role's name will be stored in.
+ ///
internal static readonly string NAME_PROPERTY = "Name";
+
+ ///
+ /// The property that the role's permissions will be stored in.
+ ///
internal static readonly string PERMISSIONS_PROPERTY = "Permissions";
public override UserRole? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
@@ -49,6 +56,12 @@ public override void Write(Utf8JsonWriter writer, UserRole value, JsonSerializer
}
+ ///
+ /// Write true/false/null for 's to .
+ ///
+ /// The to write this boolean/null to; expecting to be in an open object.
+ /// The to write about.
+ /// The coming from .
private static void QuickWritePermission(Utf8JsonWriter writer, UserPermission permission, UserRole value)
{
@@ -61,6 +74,14 @@ private static void QuickWritePermission(Utf8JsonWriter writer, UserPermission p
}
+ ///
+ /// Quickly read (string) from and remove from .
+ ///
+ /// The to read from.
+ /// The to remove from.
+ /// The to read from and to remove from .
+ /// Returns the read string.
+ /// If name is null.
private static string QuickReadString(JsonDocument document, List propertyList, string name)
{
@@ -71,6 +92,12 @@ private static string QuickReadString(JsonDocument document, List proper
}
+ ///
+ /// Read the permissions from and turn them into a .
+ ///
+ /// The to read from.
+ /// A containing modified permissions.
+ /// If a value wasn't true, false, or null.
private static Dictionary ReadPermissions(JsonDocument document)
{
diff --git a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs
index b97568a..efef5d4 100644
--- a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs
+++ b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs
@@ -5,9 +5,17 @@
namespace PMDDesktop.Server.Users.Roles;
+///
+/// Manages s. Allows the creation of new objects and handles the saving/loading of them, as well as maintaining the order of roles.
+///
public sealed class UserRoleManager : IRoleIndexable, IEnumerable
{
+ ///
+ /// Creates a new that's paied with .
+ /// This will be tightly linked to the given
+ ///
+ /// The to pair with.
internal UserRoleManager(UserManager userManager)
{
@@ -17,6 +25,9 @@ internal UserRoleManager(UserManager userManager)
}
+ ///
+ /// The that's paired with this .
+ ///
public UserManager Manager { get; private init; }
///
@@ -49,6 +60,10 @@ internal UserRoleManager(UserManager userManager)
///
internal Guid[] roleOrder = [];
+ ///
+ /// Loads from the "roles" directory and "roleOrder.json" file to retrieve saved role data.
+ ///
+ /// Task completes when loading is finished.
internal async Task LoadFromFiles()
{
@@ -111,9 +126,9 @@ internal async Task LoadAndAddRoleJson(Stream stream, Guid guid)
}
///
- /// Gets the primary JSON that contains this 's data, located inside the folder.
+ /// Returns the path to the "roleOrder.json" file, intended to be used to save/load the order of roles.
///
- /// A path pointing to where the 's json data is/should be stored.
+ /// A path poiting to the "roleOrder.json" file.
internal static string GetOrderJSONPath()
{
@@ -121,6 +136,13 @@ internal static string GetOrderJSONPath()
}
+ ///
+ /// Loads the role order from roleOrder.json. Skips if the file doesn't exist; otherwise, the file is also checked for validity and may throw.
+ ///
+ /// Task completes when role order is set or doesn't load.
+ ///
+ /// Calls to read the stream.
+ ///
[ExcludeFromCodeCoverage]
private async Task LoadRoleOrder()
{
@@ -136,6 +158,10 @@ private async Task LoadRoleOrder()
}
+ ///
+ /// Loads the role order from the provided stream in Guid[] JSON format. JSON and order is checked for validity and may throw.
+ ///
+ /// Task completes when role order is set.
internal async Task LoadRoleOrder(Stream stream)
{
@@ -153,7 +179,7 @@ internal async Task LoadRoleOrder(Stream stream)
/// Will fail if duplicated GUIDs are provided, or GUIDs that do not correspond to s in this .
///
///
- /// Task resolves once order is set and changes are saved.
+ /// Task completes once order is set and changes are saved.
public async Task SetOrder(IEnumerable guidOrder)
{
@@ -178,6 +204,10 @@ public async Task SetOrder(IEnumerable guidOrder)
}
+ ///
+ /// If is true, will write the current to "roleOrder.json".
+ ///
+ ///
public async Task WriteNewRoleOrder()
{
@@ -195,6 +225,10 @@ public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role)
return guids.TryGetValue(GUID, out role);
}
+ ///
+ /// Try to create a new , will return null if it could not be done. It will have default settings and no permissions set.
+ ///
+ /// The created , or null if it failed.
public async Task TryCreateRole()
{
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index 79e3f02..068177a 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -10,6 +10,9 @@ namespace PMDDesktop.Server.Users;
public sealed class User
{
+ ///
+ /// to use to hash passwords.
+ ///
private static PasswordHasher PASSWORD_HASHER = new();
///
@@ -59,20 +62,27 @@ internal User()
internal string? HashedPassword { get; set; }
///
- /// A unique for each . Stays consistant even when the 's handle changes.
+ /// A unique for this . Stays consistant even when the 's handle changes.
/// This should be used for long-term references to a specific .
///
[JsonIgnore]
public Guid GUID { get; internal set; } = Guid.NewGuid();
///
- ///
+ /// Attached that controls this .
///
[JsonIgnore]
public UserManager? Manager { get; private set; }
+ ///
+ /// Is/Was on the last used or currently active .
+ ///
public bool? WritingEnabled { get; private set; }
+ ///
+ /// List of s currently belonging to this .
+ /// May include expired s.
+ ///
internal List accessTokens = [];
///
@@ -83,6 +93,11 @@ internal User()
#region Name
+ ///
+ /// Set the of this .
+ ///
+ /// The new name of the .
+ /// Completes task once changes are saved.
public async Task SetName(string newName)
{
@@ -100,8 +115,8 @@ public async Task SetName(string newName)
///
/// Salt and hash the user's password, then store it in HashedPassword.
///
- ///
- ///
+ /// The plaintext password to be hashed.
+ /// Completes task after saving completes.
public async Task SetPassword(string plainText)
{
@@ -113,10 +128,10 @@ public async Task SetPassword(string plainText)
}
///
- ///
+ /// Try to set to the input. Will fail if it is not unique.
///
- ///
- ///
+ /// The string to change the new handle to.
+ /// True if the handle was changed and saved.
public async Task TrySetLoginHandle(string? newHandle)
{
@@ -141,6 +156,9 @@ public async Task TrySetLoginHandle(string? newHandle)
}
+ ///
+ /// Add the current to .
+ ///
private void AttachLoginHandle()
{
@@ -151,6 +169,9 @@ private void AttachLoginHandle()
}
+ ///
+ /// Remove the current from .
+ ///
private void DetachLoginHandle()
{
@@ -217,6 +238,11 @@ public string GetUserJSONPath()
}
+ ///
+ /// Saves all data relating to this object.
+ ///
+ /// Task completes once data is written.
+ /// If the or if is null.
[ExcludeFromCodeCoverage]
internal async Task WriteNewData()
{
@@ -243,7 +269,7 @@ internal async Task WriteNewData()
///
/// Deletes the user folder and all *explicitly written* data inside it.
///
- ///
+ /// Completes task once folder is deleted... or after it fails to delete due to having unmanaged filesystem entries.
///
/// This function does not recursive delete. Instead it has a list of items to delete. This approach is taken to minimize data loss in the event that something goes wrong.
///
@@ -301,6 +327,13 @@ public override int GetHashCode()
#region Access Tokens
+ ///
+ /// Create a new for this user.
+ ///
+ /// Returns the new .
+ ///
+ /// Will remove existing access tokens if is exceeded.
+ ///
public async Task CreateAccessToken()
{
@@ -320,6 +353,13 @@ public async Task CreateAccessToken()
}
+ ///
+ /// Attaches to the attached .
+ ///
+ /// The to attach to .
+ ///
+ /// Attaching will be skipped if is null, so this is safe to call when detached.
+ ///
private void AttachAccessToken(UserAccessToken token)
{
@@ -330,6 +370,12 @@ private void AttachAccessToken(UserAccessToken token)
}
+ ///
+ /// Revokes , removes it from the , then (optionally) saves.
+ ///
+ /// The to revoke.
+ /// Should save afterwards? Always skipped if is null.
+ /// Completes task when saving completes.
internal async Task RevokeAccessToken(UserAccessToken token, bool writeAfterwards)
{
@@ -342,6 +388,11 @@ internal async Task RevokeAccessToken(UserAccessToken token, bool writeAfterward
}
+ ///
+ /// Revokes , removes it from the , then saves.
+ ///
+ /// The to revoke.
+ /// Completes task when saving completes.
public async Task RevokeAccessToken(UserAccessToken token)
{
@@ -349,6 +400,13 @@ public async Task RevokeAccessToken(UserAccessToken token)
}
+ ///
+ /// Detaches from the attached .
+ ///
+ /// The to detach to .
+ ///
+ /// Detaching will be skipped if is null, so this is safe to call when detached.
+ ///
private void DetachAccessToken(UserAccessToken token)
{
@@ -356,6 +414,11 @@ private void DetachAccessToken(UserAccessToken token)
}
+ ///
+ /// Attempt to revoke the oldest managed by this . Can optionally save afterwards.
+ ///
+ /// Should save afterwards?
+ /// Returns true if an access token was revoked.
private async Task TryRevokeOldestAccessToken(bool writeAfterwards = true)
{
@@ -383,6 +446,11 @@ private async Task TryRevokeOldestAccessToken(bool writeAfterwards = true)
#region Manager Attaching
+ ///
+ /// Attach to a to provide this 's data to the .
+ ///
+ /// The to attach to.
+ /// Throws if already attached to a .
internal void AttachToManager(UserManager manager)
{
@@ -401,6 +469,13 @@ internal void AttachToManager(UserManager manager)
}
+ ///
+ /// Detach from the attached to remove this 's data from the attached .
+ ///
+ /// Throws if not currently attached to a .
+ ///
+ /// This doesn't really need to be called on server shutdown. It is called internally when deleting a user.
+ ///
internal void DetachFromManager()
{
@@ -452,6 +527,15 @@ public async Task TryRemoveUser()
#region Role/Permission Management
+ ///
+ /// Try to add a to this . Allows the to be influenced by the 's s.
+ ///
+ /// The to add to the .
+ /// Returns true if it was added successfully.
+ ///
+ /// Primarily fails when the is already on the .
+ /// This also saves the user.
+ ///
public async Task TryAddRole(UserRole role)
{
@@ -464,6 +548,15 @@ public async Task TryAddRole(UserRole role)
}
+ ///
+ /// Try to remove a from this . Stops the from being influenced by the 's s.
+ ///
+ /// The to remove from the .
+ /// Returns true if it was removed successfully.
+ ///
+ /// Primarily fails when the does not have the .
+ /// This also saves the user.
+ ///
public async Task TryRemoveRole(UserRole role)
{
@@ -476,6 +569,11 @@ public async Task TryRemoveRole(UserRole role)
}
+ ///
+ /// Check if the has assigned to it.
+ ///
+ /// The to check for.
+ /// True if this user is assigned this role.
public bool HasRole(UserRole role)
{
@@ -483,6 +581,12 @@ public bool HasRole(UserRole role)
}
+ ///
+ /// Does this , looking at the assigned s with respect to 's role order, have ?
+ ///
+ ///
+ /// Returns true if this user has the .
+ /// Throws if not currently attached to a .
public bool HasPermission(UserPermission permission)
{
diff --git a/PMDDesktop.Server.Users/UserAccessToken.cs b/PMDDesktop.Server.Users/UserAccessToken.cs
index ff4f71f..5fa64fb 100644
--- a/PMDDesktop.Server.Users/UserAccessToken.cs
+++ b/PMDDesktop.Server.Users/UserAccessToken.cs
@@ -5,9 +5,19 @@ namespace PMDDesktop.Server.Users;
public sealed class UserAccessToken
{
+ ///
+ /// The time until a expires.
+ ///
public static readonly TimeSpan TIME_UNTIL_EXPIRE = new(8, 0, 0, 0); // 8 days.
+
+ ///
+ /// The time until the that at which point the token should be refreshed.
+ ///
public static readonly TimeSpan REFRESH_THRESHOLD = new(7, 0, 0, 0); // 7 Days. // Ideally if you log in once a week, you'll stay logged in.
+ ///
+ /// How many random characters should be in our
+ ///
public static readonly int RANDOM_CHAR_COUNT = 64;
public static readonly char[] RANDOM_CHARS = [
@@ -17,6 +27,12 @@ public sealed class UserAccessToken
'-', '_'
];
+ ///
+ /// Create a new by manually specifying the , , and .
+ ///
+ /// The of this .
+ /// The of this .
+ /// The of this .
internal UserAccessToken(string token, User forUser, DateTime expires)
{
@@ -26,6 +42,11 @@ internal UserAccessToken(string token, User forUser, DateTime expires)
}
+ ///
+ /// Create a new, randomized for .
+ ///
+ /// The that this is intended for.
+ ///
internal static UserAccessToken CreateNewToken(User forUser)
{
@@ -35,12 +56,25 @@ internal static UserAccessToken CreateNewToken(User forUser)
}
+ ///
+ /// The that this expires.
+ ///
public DateTime ExpiryDate { get; private set; }
+ ///
+ /// The unique used to access this .
+ ///
public string TokenString { get; private set; }
+ ///
+ /// The that this points to.
+ ///
public User User { get; internal set; }
+ ///
+ /// Check if this needs a refresh according to .
+ ///
+ /// Returns true if this tokens needs a refresh.
public bool NeedsRefresh()
{
@@ -48,6 +82,10 @@ public bool NeedsRefresh()
}
+ ///
+ /// Refresh and tell the to save.
+ ///
+ /// Completes once the saving is finished.
public async Task RefreshToken()
{
@@ -57,6 +95,10 @@ public async Task RefreshToken()
}
+ ///
+ /// Check if this is currently valid. (Not expired)
+ ///
+ /// True if this token can be used.
public bool IsTokenValid()
{
@@ -66,6 +108,10 @@ public bool IsTokenValid()
}
+ ///
+ /// Tell to revoke this .
+ ///
+ /// Completes task once the token is revoked.
public async Task RevokeToken()
{
diff --git a/PMDDesktop.Server.Users/UserConverter.cs b/PMDDesktop.Server.Users/UserConverter.cs
index ae021f0..a09f887 100644
--- a/PMDDesktop.Server.Users/UserConverter.cs
+++ b/PMDDesktop.Server.Users/UserConverter.cs
@@ -57,6 +57,11 @@ public override void Write(Utf8JsonWriter writer, User value, JsonSerializerOpti
}
+ ///
+ /// Writes a single JSON object.
+ ///
+ /// The to write to.
+ /// The to write.
private static void WriteToken(Utf8JsonWriter writer, UserAccessToken token)
{
@@ -69,6 +74,14 @@ private static void WriteToken(Utf8JsonWriter writer, UserAccessToken token)
}
+ ///
+ /// Quickly read (non-nullable string) from and remove from .
+ ///
+ /// The to read from.
+ /// The to remove from.
+ /// The to read from and to remove from .
+ /// Returns the read string.
+ /// If name is null.
private static string QuickReadString(JsonDocument document, List propertyList, string name)
{
@@ -79,6 +92,13 @@ private static string QuickReadString(JsonDocument document, List proper
}
+ ///
+ /// Quickly read (nullable string) from and remove from .
+ ///
+ /// The to read from.
+ /// The to remove from.
+ /// The to read from and to remove from .
+ /// Returns the read string, or null if it is null.
private static string? QuickReadNullableString(JsonDocument document, List propertyList, string name)
{
@@ -88,6 +108,13 @@ private static string QuickReadString(JsonDocument document, List proper
}
+ ///
+ /// Reads the array of JSON objects, and returns a based on it.
+ ///
+ /// The of a .
+ /// The object to assign the created s to.
+ /// Returns a of read s.
+ /// Throws if any read token string was null.
private static List ReadTokenArray(JsonDocument document, User user)
{
diff --git a/PMDDesktop.Server.Users/UserCreationOptions.cs b/PMDDesktop.Server.Users/UserCreationOptions.cs
index 05b4968..dd769d4 100644
--- a/PMDDesktop.Server.Users/UserCreationOptions.cs
+++ b/PMDDesktop.Server.Users/UserCreationOptions.cs
@@ -1,10 +1,21 @@
-namespace PMDDesktop.Server.Users;
+namespace PMDDesktop.Server.Users;
public sealed record class UserCreationOptions
{
- public required string DisplayName {get; set;}
- public string? PlainTextPassword {get; set;}
- public string? LoginHandle {get; set;}
+ ///
+ /// The initial value to set .
+ ///
+ public required string DisplayName { get; set; }
+
+ ///
+ /// The value to hash to create .
+ ///
+ public string? PlainTextPassword { get; set; }
+
+ ///
+ /// The initial value to set . Must be unique.
+ ///
+ public string? LoginHandle { get; set; }
}
diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs
index 62c767e..e2264e5 100644
--- a/PMDDesktop.Server.Users/UserManager.cs
+++ b/PMDDesktop.Server.Users/UserManager.cs
@@ -181,6 +181,12 @@ public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user)
}
+ ///
+ /// Try to get a by their . Will return false and will be null if not found.
+ ///
+ /// The of the you're looking for.
+ /// The found , if any was found. Will be null if the return was false.
+ /// True if the is found, and will not be null.
public bool TryGetUserByLoginHandle(string handle, [NotNullWhen(true)] out User? user)
{
@@ -188,6 +194,12 @@ public bool TryGetUserByLoginHandle(string handle, [NotNullWhen(true)] out User?
}
+ ///
+ /// Try to create a new , will return null if it could not be done.
+ ///
+ /// to specify how the should be created.
+ /// The created , or null if it failed.
+ /// Primary failing condition is if the is not unique.
public async Task TryCreateUser(UserCreationOptions options)
{
diff --git a/PMDDesktop.Server.Users/UserReference.cs b/PMDDesktop.Server.Users/UserReference.cs
index 86011b9..d974a17 100644
--- a/PMDDesktop.Server.Users/UserReference.cs
+++ b/PMDDesktop.Server.Users/UserReference.cs
@@ -7,9 +7,16 @@ namespace PMDDesktop.Server.Users;
public sealed class UserReference
{
+ ///
+ /// Stored corresponding to .
+ ///
public Guid GUID { get; private set; }
-
+ ///
+ /// Create a new pointing to 's .
+ /// This allows you to find the again after serialization and deserialization.
+ ///
+ /// The you'd like to keep a reference to via .
public UserReference(User initalValue)
{
@@ -17,7 +24,11 @@ public UserReference(User initalValue)
}
-
+ ///
+ /// Create a new pointing exactly to
+ /// The existance of any with this is not checked until is called.
+ ///
+ /// The you'd like to point to.
internal UserReference(Guid directGuid)
{
@@ -25,7 +36,12 @@ internal UserReference(Guid directGuid)
}
-
+ ///
+ /// Try to get a from its .
+ ///
+ /// The to use to find .
+ /// The result of the search. Will be null if false is returned, and contain a if true.
+ /// True if the was found and has a value, otherwise false.
public bool TryGetUser(IUserIndexable indexable, [NotNullWhen(true)] out User? user)
{
From 4667d875179bfd8c38c7879949bb36a521c4a45f Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Mon, 5 Oct 2026 22:56:04 -0500
Subject: [PATCH 34/55] Check if token needs refresh before doing it
---
PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs
index ee6cecf..9dfdd1a 100644
--- a/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs
+++ b/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs
@@ -160,7 +160,8 @@ public async Task HandleRequest(HttpContext context, GameServer game)
}
if (IRequestHandler.TryGetUserFromContext(game.State.Users, context, out User? user, out UserAccessToken? token))
- await token.RefreshToken();
+ if (token.NeedsRefresh())
+ await token.RefreshToken();
try
{
From 5afdd30b9673bc5feb4d269ea5541128a6ae6814 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Tue, 6 Oct 2026 11:15:11 -0500
Subject: [PATCH 35/55] Extract creation of UserInfoResponse into function
---
PMDDesktop.Server.Users/User.cs | 7 +++++
.../GenericResponseCreators.cs | 27 +++++++++++++++++++
.../Users/GetUserInfoRequestHandler.cs | 13 +++++----
.../Requests/Users/GetUserInfoRequest.cs | 2 +-
...serInfoResponse.cs => UserInfoResponse.cs} | 4 ++-
5 files changed, 44 insertions(+), 9 deletions(-)
create mode 100644 PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs
rename PMDDesktop/Requests/Users/{GetUserInfoResponse.cs => UserInfoResponse.cs} (50%)
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index 068177a..0561fef 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -211,6 +211,13 @@ public async Task VerifyPassword(string plainText)
}
+ public bool HasPassword()
+ {
+
+ return HashedPassword is not null;
+
+ }
+
#endregion
#region Saving
diff --git a/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs b/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs
new file mode 100644
index 0000000..7004d7f
--- /dev/null
+++ b/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs
@@ -0,0 +1,27 @@
+using PMDDesktop.Requests.Users;
+using PMDDesktop.Server.Users;
+using PMDDesktop.Server.Users.Roles;
+
+namespace PMDDesktop.Server.RequestHandlers;
+
+internal static class GenericResponseCreators
+{
+
+ internal static UserInfoResponse CreateUserInfoResponse(User fromPOV, User ofUser)
+ {
+
+ bool canSeeLoginInfo = fromPOV.HasPermission(UserPermission.MANAGE_USERS);
+
+ return new()
+ {
+
+ GUID = ofUser.GUID,
+ DisplayName = ofUser.Name,
+ HasPassword = canSeeLoginInfo ? ofUser.HasPassword() : null,
+ LoginHandle = canSeeLoginInfo ? ofUser.LoginHandle : null
+
+ };
+
+ }
+
+}
diff --git a/PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs
index b2ccf27..d164118 100644
--- a/PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs
+++ b/PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs
@@ -4,20 +4,19 @@
namespace PMDDesktop.Server.RequestHandlers.Users;
-public class GetUserInfoRequestHandler : JsonRequestHandler
+public class GetUserInfoRequestHandler : JsonRequestHandler
{
- protected override async Task CreateResponse(GetUserInfoRequest request, GameServer game, User? requestingUser)
+ protected override async Task CreateResponse(GetUserInfoRequest request, GameServer game, User? requestingUser)
{
+ if (requestingUser is null)
+ throw new UserRequestException($"You need to be logged in to access this endpoint.");
+
if (!game.TryGetUser(request.GUID, out User? user))
throw new UserRequestException($"{request.GUID} does not match a user.");
- return new()
- {
- DisplayName = user.Name,
- GUID = user.GUID
- };
+ return GenericResponseCreators.CreateUserInfoResponse(requestingUser, user);
}
diff --git a/PMDDesktop/Requests/Users/GetUserInfoRequest.cs b/PMDDesktop/Requests/Users/GetUserInfoRequest.cs
index 78d431d..80c4596 100644
--- a/PMDDesktop/Requests/Users/GetUserInfoRequest.cs
+++ b/PMDDesktop/Requests/Users/GetUserInfoRequest.cs
@@ -1,7 +1,7 @@
namespace PMDDesktop.Requests.Users;
[ServerRequest("/api/users/get-info")]
-public sealed class GetUserInfoRequest : ServerRequest
+public sealed class GetUserInfoRequest : ServerRequest
{
[QueryParameter]
diff --git a/PMDDesktop/Requests/Users/GetUserInfoResponse.cs b/PMDDesktop/Requests/Users/UserInfoResponse.cs
similarity index 50%
rename from PMDDesktop/Requests/Users/GetUserInfoResponse.cs
rename to PMDDesktop/Requests/Users/UserInfoResponse.cs
index 7583ea9..a143260 100644
--- a/PMDDesktop/Requests/Users/GetUserInfoResponse.cs
+++ b/PMDDesktop/Requests/Users/UserInfoResponse.cs
@@ -1,9 +1,11 @@
namespace PMDDesktop.Requests.Users;
-public sealed class GetUserInfoResponse : ServerResponse
+public sealed class UserInfoResponse : ServerResponse
{
public required string DisplayName { get; set; }
public required Guid GUID { get; set; }
+ public string? LoginHandle { get; set; }
+ public bool? HasPassword { get; set; }
}
From d3bff4b32f74c6995a6c7f07649f7a9cb7939d11 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Tue, 6 Oct 2026 11:50:26 -0500
Subject: [PATCH 36/55] Add IRoleIndexable to GameServer and GameState
---
PMDDesktop.Server/Game/GameServer.cs | 9 ++++++++-
PMDDesktop.Server/Game/GameState.cs | 7 ++++++-
2 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/PMDDesktop.Server/Game/GameServer.cs b/PMDDesktop.Server/Game/GameServer.cs
index 5e29772..df122b2 100644
--- a/PMDDesktop.Server/Game/GameServer.cs
+++ b/PMDDesktop.Server/Game/GameServer.cs
@@ -1,11 +1,12 @@
using PMDDesktop.Server.Assets;
using PMDDesktop.Server.Saving;
using PMDDesktop.Server.Users;
+using PMDDesktop.Server.Users.Roles;
using System.Diagnostics.CodeAnalysis;
namespace PMDDesktop.Server.Game;
-public class GameServer : IAssetIndexable, ISaveDataIndexable, IUserIndexable
+public class GameServer : IAssetIndexable, ISaveDataIndexable, IUserIndexable, IRoleIndexable
{
public GameServer()
@@ -79,4 +80,10 @@ public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user)
{
return State.TryGetUser(GUID, out user);
}
+
+ public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role)
+ {
+ return ((IRoleIndexable)State).TryGetRole(GUID, out role);
+ }
+
}
diff --git a/PMDDesktop.Server/Game/GameState.cs b/PMDDesktop.Server/Game/GameState.cs
index 1aa0c65..0250dba 100644
--- a/PMDDesktop.Server/Game/GameState.cs
+++ b/PMDDesktop.Server/Game/GameState.cs
@@ -6,7 +6,7 @@
namespace PMDDesktop.Server.Game;
-public class GameState : IAssetIndexable, ISaveDataIndexable, IUserIndexable
+public class GameState : IAssetIndexable, ISaveDataIndexable, IUserIndexable, IRoleIndexable
{
public GameState()
@@ -66,6 +66,11 @@ public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user)
return Users.TryGetUser(GUID, out user);
}
+ public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role)
+ {
+ return Roles.TryGetRole(GUID, out role);
+ }
+
public SaveDataManager Saves { get; }
public AssetManager Assets { get; }
public UserManager Users { get; }
From a9c7a0f22c8ee50725850abbbb79a534cc657b43 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Tue, 6 Oct 2026 20:51:22 -0500
Subject: [PATCH 37/55] Split UserTests between UserManagerTests
---
.../UserManagerTests.cs | 33 +++++++++++++++++++
PMDDesktop.Server.Users.Tests/UserTests.cs | 18 ----------
2 files changed, 33 insertions(+), 18 deletions(-)
create mode 100644 PMDDesktop.Server.Users.Tests/UserManagerTests.cs
diff --git a/PMDDesktop.Server.Users.Tests/UserManagerTests.cs b/PMDDesktop.Server.Users.Tests/UserManagerTests.cs
new file mode 100644
index 0000000..1345b05
--- /dev/null
+++ b/PMDDesktop.Server.Users.Tests/UserManagerTests.cs
@@ -0,0 +1,33 @@
+using PMDDesktop.Server.Users.Roles;
+
+namespace PMDDesktop.Server.Users.Tests;
+
+public class UserManagerTests
+{
+
+ public static readonly UserCreationOptions DEFAULT_USER_OPTIONS = new()
+ {
+ DisplayName = "Test User",
+ LoginHandle = "test",
+ PlainTextPassword = "Test123"
+ };
+
+ [Fact]
+ public async Task GetViaGUID()
+ {
+
+ UserManager manager = new();
+ User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
+ Assert.NotNull(user);
+
+ Guid guid = user.GUID;
+
+ Assert.True(manager.TryGetUser(guid, out User? guidUser));
+ Assert.NotNull(guidUser);
+
+ Assert.False(manager.TryGetUser(Guid.Empty, out User? notGuidUser));
+ Assert.Null(notGuidUser);
+
+ }
+
+}
diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs
index 7ec9ff5..5963ea5 100644
--- a/PMDDesktop.Server.Users.Tests/UserTests.cs
+++ b/PMDDesktop.Server.Users.Tests/UserTests.cs
@@ -88,24 +88,6 @@ public async Task LoginHandleChanges()
}
- [Fact]
- public async Task GetViaGUID()
- {
-
- UserManager manager = new();
- User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
- Assert.NotNull(user);
-
- Guid guid = user.GUID;
-
- Assert.True(manager.TryGetUser(guid, out User? guidUser));
- Assert.NotNull(guidUser);
-
- Assert.False(manager.TryGetUser(Guid.Empty, out User? notGuidUser));
- Assert.Null(notGuidUser);
-
- }
-
[Fact]
public async Task IsTokenLimitEnforced()
{
From e7b30c3830936a5e04b802a3467d547bca9e0226 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Tue, 6 Oct 2026 21:00:15 -0500
Subject: [PATCH 38/55] Add Admin users and Tests
---
PMDDesktop.Server.Users.Tests/UserTests.cs | 23 +++++++++++++++++
PMDDesktop.Server.Users/User.cs | 25 +++++++++++++++++++
PMDDesktop.Server.Users/UserConverter.cs | 20 ++++++++++++++-
.../UserCreationOptions.cs | 2 ++
PMDDesktop.Server.Users/UserManager.cs | 3 ++-
5 files changed, 71 insertions(+), 2 deletions(-)
diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs
index 5963ea5..e83ecb7 100644
--- a/PMDDesktop.Server.Users.Tests/UserTests.cs
+++ b/PMDDesktop.Server.Users.Tests/UserTests.cs
@@ -241,4 +241,27 @@ public async Task UserPermissionsRespectHierarchy()
}
+ [Fact]
+ public async Task AdminUserGetsPermissions()
+ {
+
+ UserManager manager = new();
+ User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS);
+ Assert.NotNull(user);
+
+ // This test assumes that MANAGE_ROLE has AlwaysForAdmin enabled.
+ Assert.True(UserPermission.MANAGE_ROLES.AlwaysForAdmin);
+
+ Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES));
+
+ await user.SetAdmin(true);
+
+ Assert.True(user.HasPermission(UserPermission.MANAGE_ROLES));
+
+ await user.SetAdmin(false);
+
+ Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES));
+
+ }
+
}
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index 0561fef..f49963d 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -79,6 +79,11 @@ internal User()
///
public bool? WritingEnabled { get; private set; }
+ ///
+ /// Specifies if this user is affected by .
+ ///
+ public bool IsAdmin { get; internal set; }
+
///
/// List of s currently belonging to this .
/// May include expired s.
@@ -600,6 +605,11 @@ public bool HasPermission(UserPermission permission)
if (Manager is null)
throw new NullReferenceException($"Can't check if {this} has a permission if {nameof(Manager)} is null!");
+ // Is this an admin permission?
+ // Checked after Manager because we shouldn't be checking for permissions on a detached user anyways.
+ if (permission.AlwaysForAdmin && IsAdmin)
+ return true;
+
foreach (UserRole role in Manager.RoleManager)
{
@@ -617,6 +627,21 @@ public bool HasPermission(UserPermission permission)
}
+ ///
+ /// Set whether this is an admin and is affected by .
+ ///
+ /// Whether or not this should be an admin.
+ /// Completes task once changes are saved.
+ public async Task SetAdmin(bool newState)
+ {
+
+ IsAdmin = newState;
+
+ if (Manager is not null)
+ await WriteNewData();
+
+ }
+
#endregion
}
diff --git a/PMDDesktop.Server.Users/UserConverter.cs b/PMDDesktop.Server.Users/UserConverter.cs
index a09f887..8b22e8f 100644
--- a/PMDDesktop.Server.Users/UserConverter.cs
+++ b/PMDDesktop.Server.Users/UserConverter.cs
@@ -23,7 +23,8 @@ public override User Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSer
{
Name = QuickReadString(json, propertyList, nameof(User.Name)),
HashedPassword = QuickReadNullableString(json, propertyList, nameof(User.HashedPassword)),
- LoginHandle = QuickReadNullableString(json, propertyList, nameof(User.LoginHandle))
+ LoginHandle = QuickReadNullableString(json, propertyList, nameof(User.LoginHandle)),
+ IsAdmin = QuickReadBoolean(json, propertyList, nameof(User.IsAdmin))
};
propertyList.Remove(TOKENS_PROPERTY_NAME);
@@ -47,6 +48,7 @@ public override void Write(Utf8JsonWriter writer, User value, JsonSerializerOpti
writer.WriteString(nameof(User.Name), value.Name);
writer.WriteString(nameof(User.HashedPassword), value.HashedPassword);
writer.WriteString(nameof(User.LoginHandle), value.LoginHandle);
+ writer.WriteBoolean(nameof(User.IsAdmin), value.IsAdmin);
writer.WriteStartArray(TOKENS_PROPERTY_NAME);
foreach (UserAccessToken token in value.accessTokens)
@@ -108,6 +110,22 @@ private static string QuickReadString(JsonDocument document, List proper
}
+ ///
+ /// Quickly read (boolean) from and remove from .
+ ///
+ /// The to read from.
+ /// The to remove from.
+ /// The to read from and to remove from .
+ /// Returns the read boolean.
+ private static bool QuickReadBoolean(JsonDocument document, List propertyList, string name)
+ {
+
+ propertyList.Remove(name);
+
+ return document.RootElement.GetProperty(name).GetBoolean();
+
+ }
+
///
/// Reads the array of JSON objects, and returns a based on it.
///
diff --git a/PMDDesktop.Server.Users/UserCreationOptions.cs b/PMDDesktop.Server.Users/UserCreationOptions.cs
index dd769d4..c270313 100644
--- a/PMDDesktop.Server.Users/UserCreationOptions.cs
+++ b/PMDDesktop.Server.Users/UserCreationOptions.cs
@@ -18,4 +18,6 @@ public sealed record class UserCreationOptions
///
public string? LoginHandle { get; set; }
+ public bool IsAdmin { get; set; } = false;
+
}
diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs
index e2264e5..af7470b 100644
--- a/PMDDesktop.Server.Users/UserManager.cs
+++ b/PMDDesktop.Server.Users/UserManager.cs
@@ -205,7 +205,8 @@ public bool TryGetUserByLoginHandle(string handle, [NotNullWhen(true)] out User?
User user = new()
{
- Name = options.DisplayName
+ Name = options.DisplayName,
+ IsAdmin = options.IsAdmin
};
if (options.LoginHandle is not null)
From 7570b09bd84dab94fb7876dd249f5005c82bfb2a Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Tue, 6 Oct 2026 21:04:10 -0500
Subject: [PATCH 39/55] Add default admin user. (For when files loaded with no
users)
---
.../UserManagerTests.cs | 13 ++++++++++
PMDDesktop.Server.Users/UserManager.cs | 26 +++++++++++++++++++
2 files changed, 39 insertions(+)
diff --git a/PMDDesktop.Server.Users.Tests/UserManagerTests.cs b/PMDDesktop.Server.Users.Tests/UserManagerTests.cs
index 1345b05..9eddc48 100644
--- a/PMDDesktop.Server.Users.Tests/UserManagerTests.cs
+++ b/PMDDesktop.Server.Users.Tests/UserManagerTests.cs
@@ -30,4 +30,17 @@ public async Task GetViaGUID()
}
+ [Fact]
+ public async Task DefaultAdminUserIsAdmin()
+ {
+
+ UserManager manager = new();
+
+ User? user = await manager.TryCreateDefaultAdminUser();
+ Assert.NotNull(user);
+
+ Assert.True(user.IsAdmin);
+
+ }
+
}
diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs
index af7470b..1cfdff9 100644
--- a/PMDDesktop.Server.Users/UserManager.cs
+++ b/PMDDesktop.Server.Users/UserManager.cs
@@ -8,6 +8,16 @@ namespace PMDDesktop.Server.Users;
public sealed class UserManager : IEnumerable, IUserIndexable, IRoleIndexable
{
+ private static readonly UserCreationOptions DEFAULT_ADMIN_OPTIONS = new()
+ {
+
+ DisplayName = "Default Admin User",
+ LoginHandle = "admin",
+ PlainTextPassword = "admin",
+ IsAdmin = true
+
+ };
+
public UserManager()
{
@@ -102,6 +112,10 @@ private async Task LoadAllUserData()
}
+ // If no users were loaded, make a default Admin user to ensure there's a way to log in.
+ if (guids.Count == 0)
+ await TryCreateDefaultAdminUser();
+
}
///
@@ -221,6 +235,18 @@ public bool TryGetUserByLoginHandle(string handle, [NotNullWhen(true)] out User?
}
+ ///
+ /// Create a new User account with handle "admin" and password "admin" and admin permissions.
+ /// Useful when starting the server for the first time without any users.
+ ///
+ ///
+ internal async Task TryCreateDefaultAdminUser()
+ {
+
+ return await TryCreateUser(DEFAULT_ADMIN_OPTIONS);
+
+ }
+
public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role)
{
return RoleManager.TryGetRole(GUID, out role);
From f25ae0fa0ee8ffc0125691ccaa9ce4ec198b3ac6 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Tue, 6 Oct 2026 21:37:10 -0500
Subject: [PATCH 40/55] Create directories before writing files.
---
PMDDesktop.Server.Users/Roles/UserRole.cs | 10 +++++++++-
PMDDesktop.Server.Users/User.cs | 1 +
2 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/PMDDesktop.Server.Users/Roles/UserRole.cs b/PMDDesktop.Server.Users/Roles/UserRole.cs
index 6333e77..805db67 100644
--- a/PMDDesktop.Server.Users/Roles/UserRole.cs
+++ b/PMDDesktop.Server.Users/Roles/UserRole.cs
@@ -168,7 +168,14 @@ internal void DetachFromManager()
internal string GetRoleJSONPath()
{
- return Path.Combine(AppContext.BaseDirectory, "roles", $"{GUID}.json");
+ return Path.Combine(GetRoleFolder(), $"{GUID}.json");
+
+ }
+
+ static internal string GetRoleFolder()
+ {
+
+ return Path.Combine(AppContext.BaseDirectory, "roles");
}
@@ -190,6 +197,7 @@ internal async Task WriteNewData()
if (WritingEnabled != true)
return;
+ Directory.CreateDirectory(GetRoleFolder());
using FileStream jsonFile = File.Create(GetRoleJSONPath());
await JsonSerializer.SerializeAsync(jsonFile, this, AppInfo.JSON_OPTIONS);
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index f49963d..89a21e5 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -272,6 +272,7 @@ internal async Task WriteNewData()
if (WritingEnabled != true)
return;
+ Directory.CreateDirectory(GetUserFolder());
using FileStream jsonFile = File.Create(GetUserJSONPath());
await JsonSerializer.SerializeAsync(jsonFile, this, AppInfo.JSON_OPTIONS);
From ce22554fa8b3c0928f08f9a987bf4e327eb56219 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Wed, 7 Oct 2026 00:00:21 -0500
Subject: [PATCH 41/55] Add endpoint to setting user names.
---
.../Users/SetUserNameRequestHandler.cs | 31 +++++++++++++++++++
.../Requests/Users/SetUserNameRequest.cs | 11 +++++++
2 files changed, 42 insertions(+)
create mode 100644 PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs
create mode 100644 PMDDesktop/Requests/Users/SetUserNameRequest.cs
diff --git a/PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs
new file mode 100644
index 0000000..c0b961a
--- /dev/null
+++ b/PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs
@@ -0,0 +1,31 @@
+using PMDDesktop.Requests.Users;
+using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
+using PMDDesktop.Server.Users.Roles;
+
+namespace PMDDesktop.Server.RequestHandlers.Users;
+
+public class SetUserNameRequestHandler : JsonRequestHandler
+{
+
+ protected override async Task CreateResponse(SetUserNameRequest request, GameServer game, User? requestingUser)
+ {
+
+ if (requestingUser is null)
+ throw new UserRequestException($"You need to be logged in to access this endpoint.");
+
+ if (!game.TryGetUser(request.GUID, out User? user))
+ throw new UserRequestException($"{request.GUID} does not match a user.");
+
+ // To change the username of someone who isn't yourself, you need the MANAGE_USERS permission.
+ if (user != requestingUser)
+ if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS))
+ throw new UserRequestException($"You need permission to manage users to access this endpoint.");
+
+ await user.SetName(request.NewName);
+
+ return GenericResponseCreators.CreateUserInfoResponse(requestingUser, user);
+
+ }
+
+}
diff --git a/PMDDesktop/Requests/Users/SetUserNameRequest.cs b/PMDDesktop/Requests/Users/SetUserNameRequest.cs
new file mode 100644
index 0000000..0fc1737
--- /dev/null
+++ b/PMDDesktop/Requests/Users/SetUserNameRequest.cs
@@ -0,0 +1,11 @@
+namespace PMDDesktop.Requests.Users;
+
+[ServerRequest("/api/users/set-name")]
+public sealed class SetUserNameRequest : ServerRequest
+{
+
+ public required Guid GUID { get; set; }
+
+ public required string NewName { get; set; }
+
+}
From d8c9a0f3ad1b37060a9f3c098d67dfe3e9a297cc Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Wed, 7 Oct 2026 22:23:03 -0500
Subject: [PATCH 42/55] Allow SetPassword(string?) to take null.
---
PMDDesktop.Server.Users/User.cs | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index 89a21e5..cf379bc 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -120,12 +120,15 @@ public async Task SetName(string newName)
///
/// Salt and hash the user's password, then store it in HashedPassword.
///
- /// The plaintext password to be hashed.
+ /// The plaintext password to be hashed.
/// Completes task after saving completes.
- public async Task SetPassword(string plainText)
+ public async Task SetPassword(string? newPassword)
{
- HashedPassword = PASSWORD_HASHER.HashPassword(this, plainText);
+ if (newPassword is string passwordText)
+ HashedPassword = PASSWORD_HASHER.HashPassword(this, passwordText);
+ else
+ HashedPassword = null;
if (Manager is not null)
await WriteNewData();
From 82f0fef31f42c3c1b19409655353b74b8d2ca666 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Wed, 7 Oct 2026 23:30:07 -0500
Subject: [PATCH 43/55] Add endpoints for changing password & handle.
---
.../Users/SetUserLoginHandleRequestHandler.cs | 39 +++++++++++++++++++
.../Users/SetUserPasswordRequestHandler.cs | 31 +++++++++++++++
.../Users/SetUserLoginHandleRequest.cs | 19 +++++++++
.../Requests/Users/SetUserPasswordRequest.cs | 18 +++++++++
4 files changed, 107 insertions(+)
create mode 100644 PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs
create mode 100644 PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs
create mode 100644 PMDDesktop/Requests/Users/SetUserLoginHandleRequest.cs
create mode 100644 PMDDesktop/Requests/Users/SetUserPasswordRequest.cs
diff --git a/PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs
new file mode 100644
index 0000000..7ab3bd9
--- /dev/null
+++ b/PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs
@@ -0,0 +1,39 @@
+using PMDDesktop.Requests.Users;
+using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
+using PMDDesktop.Server.Users.Roles;
+
+namespace PMDDesktop.Server.RequestHandlers.Users;
+
+public class SetUserLoginHandleRequestHandler : JsonRequestHandler
+{
+
+ protected override async Task CreateResponse(SetUserLoginHandleRequest request, GameServer game, User? requestingUser)
+ {
+
+ if (requestingUser is null)
+ throw new UserRequestException($"You need to be logged in to access this endpoint.");
+
+ if (!game.TryGetUser(request.GUID, out User? user))
+ throw new UserRequestException($"{request.GUID} does not match a user.");
+
+ // To change the login handle of someone who isn't yourself, you need the MANAGE_USERS permission.
+ if (user != requestingUser)
+ if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS))
+ throw new UserRequestException($"You need permission to manage users to access this endpoint.");
+
+ // TEMPORARY SOLUTION
+ // TODO: Replace this with a more elegant solution. I think we should make a new class or struct to hold these types of super normalized strings.
+ bool validHandle = request.NewLoginHandle is null || request.NewLoginHandle.All(letter => char.IsAsciiLetterLower(letter) || char.IsAsciiDigit(letter) || letter == '-');
+
+ if (!validHandle)
+ throw new UserRequestException($"The handle is invalid. Please make sure you use lowercase letters, numbers, or hyphens.");
+
+ if (!await user.TrySetLoginHandle(request.NewLoginHandle))
+ throw new UserRequestException($"Unable to set the login handle to '{request.NewLoginHandle}'. It may not be unique.");
+
+ return GenericResponseCreators.CreateUserInfoResponse(requestingUser, user);
+
+ }
+
+}
diff --git a/PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs
new file mode 100644
index 0000000..3e4fd48
--- /dev/null
+++ b/PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs
@@ -0,0 +1,31 @@
+using PMDDesktop.Requests.Users;
+using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
+using PMDDesktop.Server.Users.Roles;
+
+namespace PMDDesktop.Server.RequestHandlers.Users;
+
+public class SetUserPasswordRequestHandler : JsonRequestHandler
+{
+
+ protected override async Task CreateResponse(SetUserPasswordRequest request, GameServer game, User? requestingUser)
+ {
+
+ if (requestingUser is null)
+ throw new UserRequestException($"You need to be logged in to access this endpoint.");
+
+ if (!game.TryGetUser(request.GUID, out User? user))
+ throw new UserRequestException($"{request.GUID} does not match a user.");
+
+ // To change the login handle of someone who isn't yourself, you need the MANAGE_USERS permission.
+ if (user != requestingUser)
+ if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS))
+ throw new UserRequestException($"You need permission to manage users to access this endpoint.");
+
+ await user.SetPassword(request.NewPassword);
+
+ return GenericResponseCreators.CreateUserInfoResponse(requestingUser, user);
+
+ }
+
+}
diff --git a/PMDDesktop/Requests/Users/SetUserLoginHandleRequest.cs b/PMDDesktop/Requests/Users/SetUserLoginHandleRequest.cs
new file mode 100644
index 0000000..e0a55d9
--- /dev/null
+++ b/PMDDesktop/Requests/Users/SetUserLoginHandleRequest.cs
@@ -0,0 +1,19 @@
+namespace PMDDesktop.Requests.Users;
+
+[ServerRequest("/api/users/set-handle")]
+public sealed class SetUserLoginHandleRequest : ServerRequest
+{
+
+ ///
+ /// The of the user you're trying to set the login handle of.
+ ///
+ public required Guid GUID { get; set; }
+
+ ///
+ /// The login handle for the account.
+ /// Can be null to unset the handle (to prevent logins with handle/password).
+ /// TEMP: handles must be lowercase, 0-9, or hyphen (-).
+ ///
+ public required string? NewLoginHandle { get; set; }
+
+}
diff --git a/PMDDesktop/Requests/Users/SetUserPasswordRequest.cs b/PMDDesktop/Requests/Users/SetUserPasswordRequest.cs
new file mode 100644
index 0000000..f8b061c
--- /dev/null
+++ b/PMDDesktop/Requests/Users/SetUserPasswordRequest.cs
@@ -0,0 +1,18 @@
+namespace PMDDesktop.Requests.Users;
+
+[ServerRequest("/api/users/set-password")]
+public sealed class SetUserPasswordRequest : ServerRequest
+{
+
+ ///
+ /// The of the user you're trying to set the password of.
+ ///
+ public required Guid GUID { get; set; }
+
+ ///
+ /// The new password for the account (plain text; hashed server-side).
+ /// Can be null to unset the password (to prevent logins with handle/password).
+ ///
+ public required string? NewPassword { get; set; }
+
+}
From e700a88f932ac5b8738491b4068089eae848f5f5 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Wed, 7 Oct 2026 23:49:34 -0500
Subject: [PATCH 44/55] Add endpoint to create users.
---
.../Users/CreateUserRequestHandler.cs | 45 +++++++++++++++++++
.../Requests/Users/CreateUserRequest.cs | 13 ++++++
2 files changed, 58 insertions(+)
create mode 100644 PMDDesktop.Server/RequestHandlers/Users/CreateUserRequestHandler.cs
create mode 100644 PMDDesktop/Requests/Users/CreateUserRequest.cs
diff --git a/PMDDesktop.Server/RequestHandlers/Users/CreateUserRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/CreateUserRequestHandler.cs
new file mode 100644
index 0000000..7246c19
--- /dev/null
+++ b/PMDDesktop.Server/RequestHandlers/Users/CreateUserRequestHandler.cs
@@ -0,0 +1,45 @@
+using PMDDesktop.Requests.Users;
+using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
+using PMDDesktop.Server.Users.Roles;
+
+namespace PMDDesktop.Server.RequestHandlers.Users;
+
+public class CreateUserRequestHandler : JsonRequestHandler
+{
+
+ protected override async Task CreateResponse(CreateUserRequest request, GameServer game, User? requestingUser)
+ {
+
+ if (requestingUser is null)
+ throw new UserRequestException($"You need to be logged in to access this endpoint.");
+
+ // To create users, you need the MANAGE_USERS permission.
+ if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS))
+ throw new UserRequestException($"You need permission to manage users to access this endpoint.");
+
+ // TEMPORARY SOLUTION
+ // TODO: Replace this with a more elegant solution. I think we should make a new class or struct to hold these types of super normalized strings.
+ bool validHandle = request.NewLoginHandle is null || request.NewLoginHandle.All(letter => char.IsAsciiLetterLower(letter) || char.IsAsciiDigit(letter) || letter == '-');
+
+ if (!validHandle)
+ throw new UserRequestException($"The handle is invalid. Please make sure you use lowercase letters, numbers, or hyphens.");
+
+ if (request.NewLoginHandle is not null && game.State.Users.TryGetUserByLoginHandle(request.NewLoginHandle, out _))
+ throw new UserRequestException($"Provided login handle '{request.NewLoginHandle}' is already in use.");
+
+ UserCreationOptions options = new()
+ {
+ DisplayName = request.NewName,
+ LoginHandle = request.NewLoginHandle,
+ PlainTextPassword = request.NewPassword
+ };
+
+ User user = await game.State.Users.TryCreateUser(options)
+ ?? throw new Exception($"Unable to create user.");
+
+ return GenericResponseCreators.CreateUserInfoResponse(requestingUser, user);
+
+ }
+
+}
diff --git a/PMDDesktop/Requests/Users/CreateUserRequest.cs b/PMDDesktop/Requests/Users/CreateUserRequest.cs
new file mode 100644
index 0000000..f91ea13
--- /dev/null
+++ b/PMDDesktop/Requests/Users/CreateUserRequest.cs
@@ -0,0 +1,13 @@
+namespace PMDDesktop.Requests.Users;
+
+[ServerRequest("/api/users/create")]
+public sealed class CreateUserRequest : ServerRequest
+{
+
+ public required string NewName { get; set; }
+
+ public required string? NewLoginHandle { get; set; }
+
+ public required string? NewPassword { get; set; }
+
+}
From 2e354fff19c5c7c3c682766a467e98e80ad5cdeb Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Thu, 8 Oct 2026 01:11:39 -0500
Subject: [PATCH 45/55] Add endpoint to list users.
---
.../Users/ListUsersRequestHandler.cs | 23 +++++++++++++++++++
PMDDesktop/Requests/Users/ListUsersRequest.cs | 7 ++++++
.../Requests/Users/ListUsersResponse.cs | 11 +++++++++
3 files changed, 41 insertions(+)
create mode 100644 PMDDesktop.Server/RequestHandlers/Users/ListUsersRequestHandler.cs
create mode 100644 PMDDesktop/Requests/Users/ListUsersRequest.cs
create mode 100644 PMDDesktop/Requests/Users/ListUsersResponse.cs
diff --git a/PMDDesktop.Server/RequestHandlers/Users/ListUsersRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/ListUsersRequestHandler.cs
new file mode 100644
index 0000000..db728de
--- /dev/null
+++ b/PMDDesktop.Server/RequestHandlers/Users/ListUsersRequestHandler.cs
@@ -0,0 +1,23 @@
+using PMDDesktop.Requests.Users;
+using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
+
+namespace PMDDesktop.Server.RequestHandlers.Users;
+
+public class ListUsersRequestHandler : JsonRequestHandler
+{
+
+ protected override async Task CreateResponse(ListUsersRequest request, GameServer game, User? requestingUser)
+ {
+
+ if (requestingUser is null)
+ throw new UserRequestException($"You need to be logged in to access this endpoint.");
+
+ return new()
+ {
+ Users = [.. game.State.Users.Select(user => GenericResponseCreators.CreateUserInfoResponse(requestingUser, user))]
+ };
+
+ }
+
+}
diff --git a/PMDDesktop/Requests/Users/ListUsersRequest.cs b/PMDDesktop/Requests/Users/ListUsersRequest.cs
new file mode 100644
index 0000000..2e3e7d3
--- /dev/null
+++ b/PMDDesktop/Requests/Users/ListUsersRequest.cs
@@ -0,0 +1,7 @@
+namespace PMDDesktop.Requests.Users;
+
+[ServerRequest("/api/users/list")]
+public sealed class ListUsersRequest : ServerRequest
+{
+
+}
diff --git a/PMDDesktop/Requests/Users/ListUsersResponse.cs b/PMDDesktop/Requests/Users/ListUsersResponse.cs
new file mode 100644
index 0000000..fa84038
--- /dev/null
+++ b/PMDDesktop/Requests/Users/ListUsersResponse.cs
@@ -0,0 +1,11 @@
+namespace PMDDesktop.Requests.Users;
+
+public sealed class ListUsersResponse : ServerResponse
+{
+
+ ///
+ /// All currently existing users, presented in an arbitrary order.
+ ///
+ public required UserInfoResponse[] Users { get; set; }
+
+}
From f331b3f2578e43dd2f6e179359bafd3f573cbf92 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Thu, 8 Oct 2026 01:45:37 -0500
Subject: [PATCH 46/55] Add endpoints to get/list roles
---
PMDDesktop.Server.Users/Roles/UserRole.cs | 21 +++++++++-
.../GenericResponseCreators.cs | 38 ++++++++++++++++++-
.../Roles/GetRoleInfoRequestHandler.cs | 24 ++++++++++++
.../Roles/ListUsersRequestHandler.cs | 23 +++++++++++
.../Requests/Roles/GetRoleInfoRequest.cs | 10 +++++
PMDDesktop/Requests/Roles/ListRolesRequest.cs | 7 ++++
.../Requests/Roles/ListRolesResponse.cs | 11 ++++++
PMDDesktop/Requests/Roles/RoleInfoResponse.cs | 32 ++++++++++++++++
8 files changed, 164 insertions(+), 2 deletions(-)
create mode 100644 PMDDesktop.Server/RequestHandlers/Roles/GetRoleInfoRequestHandler.cs
create mode 100644 PMDDesktop.Server/RequestHandlers/Roles/ListUsersRequestHandler.cs
create mode 100644 PMDDesktop/Requests/Roles/GetRoleInfoRequest.cs
create mode 100644 PMDDesktop/Requests/Roles/ListRolesRequest.cs
create mode 100644 PMDDesktop/Requests/Roles/ListRolesResponse.cs
create mode 100644 PMDDesktop/Requests/Roles/RoleInfoResponse.cs
diff --git a/PMDDesktop.Server.Users/Roles/UserRole.cs b/PMDDesktop.Server.Users/Roles/UserRole.cs
index 805db67..bbfcc9d 100644
--- a/PMDDesktop.Server.Users/Roles/UserRole.cs
+++ b/PMDDesktop.Server.Users/Roles/UserRole.cs
@@ -172,7 +172,7 @@ internal string GetRoleJSONPath()
}
- static internal string GetRoleFolder()
+ internal static string GetRoleFolder()
{
return Path.Combine(AppContext.BaseDirectory, "roles");
@@ -253,4 +253,23 @@ public async Task TryRemoveRole()
}
+ ///
+ /// Returns the current index that this role is slotted in the role order.
+ /// Starts at 0, and ends at the current role count minus 1.
+ /// Numbers closer to 0 are first and have a higher priority in permission evaluation.
+ ///
+ /// The index this role is located at in the role order.
+ public int GetCurrentOrderIndex()
+ {
+
+ if (Manager is null)
+ throw new InvalidOperationException($"{this} can't check its order index when {nameof(Manager)} is null!");
+
+ if (IsDefaultRole) // We can just assume this is the last role. It's faster + it's not even in the role order.
+ return Manager.roleOrder.Length;
+
+ return Manager.roleOrder.IndexOf(GUID);
+
+ }
+
}
diff --git a/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs b/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs
index 7004d7f..b54f458 100644
--- a/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs
+++ b/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs
@@ -1,4 +1,5 @@
-using PMDDesktop.Requests.Users;
+using PMDDesktop.Requests.Roles;
+using PMDDesktop.Requests.Users;
using PMDDesktop.Server.Users;
using PMDDesktop.Server.Users.Roles;
@@ -24,4 +25,39 @@ internal static UserInfoResponse CreateUserInfoResponse(User fromPOV, User ofUse
}
+ internal static RoleInfoResponse CreateRoleInfoResponse(User fromPOV, UserRole ofRole)
+ {
+
+ bool canSeePermissionInfo = fromPOV.HasPermission(UserPermission.MANAGE_USERS);
+
+ Dictionary? permissions;
+
+ if (canSeePermissionInfo)
+ {
+
+ permissions = [];
+ foreach (UserPermission permission in UserPermission.ALL)
+ permissions.Add(permission.DataName, ofRole.GetPermission(permission));
+
+ }
+ else
+ {
+
+ permissions = null;
+
+ }
+
+ return new()
+ {
+
+ DisplayName = ofRole.Name,
+ GUID = ofRole.GUID,
+ Permissions = permissions,
+ IndexInRoleOrder = ofRole.GetCurrentOrderIndex(),
+ IsDefaultRole = ofRole.IsDefaultRole
+
+ };
+
+ }
+
}
diff --git a/PMDDesktop.Server/RequestHandlers/Roles/GetRoleInfoRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Roles/GetRoleInfoRequestHandler.cs
new file mode 100644
index 0000000..ac72557
--- /dev/null
+++ b/PMDDesktop.Server/RequestHandlers/Roles/GetRoleInfoRequestHandler.cs
@@ -0,0 +1,24 @@
+using PMDDesktop.Requests.Roles;
+using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
+using PMDDesktop.Server.Users.Roles;
+
+namespace PMDDesktop.Server.RequestHandlers.Roles;
+
+public class GetRoleInfoRequestHandler : JsonRequestHandler
+{
+
+ protected override async Task CreateResponse(GetRoleInfoRequest request, GameServer game, User? requestingUser)
+ {
+
+ if (requestingUser is null)
+ throw new UserRequestException($"You need to be logged in to access this endpoint.");
+
+ if (!game.TryGetRole(request.GUID, out UserRole? role))
+ throw new UserRequestException($"{request.GUID} does not match a role.");
+
+ return GenericResponseCreators.CreateRoleInfoResponse(requestingUser, role);
+
+ }
+
+}
diff --git a/PMDDesktop.Server/RequestHandlers/Roles/ListUsersRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Roles/ListUsersRequestHandler.cs
new file mode 100644
index 0000000..6d2de5a
--- /dev/null
+++ b/PMDDesktop.Server/RequestHandlers/Roles/ListUsersRequestHandler.cs
@@ -0,0 +1,23 @@
+using PMDDesktop.Requests.Roles;
+using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
+
+namespace PMDDesktop.Server.RequestHandlers.Roles;
+
+public class ListRolesRequestHandler : JsonRequestHandler
+{
+
+ protected override async Task CreateResponse(ListRolesRequest request, GameServer game, User? requestingUser)
+ {
+
+ if (requestingUser is null)
+ throw new UserRequestException($"You need to be logged in to access this endpoint.");
+
+ return new()
+ {
+ Roles = [.. game.State.Roles.Select(role => GenericResponseCreators.CreateRoleInfoResponse(requestingUser, role))]
+ };
+
+ }
+
+}
diff --git a/PMDDesktop/Requests/Roles/GetRoleInfoRequest.cs b/PMDDesktop/Requests/Roles/GetRoleInfoRequest.cs
new file mode 100644
index 0000000..e853c37
--- /dev/null
+++ b/PMDDesktop/Requests/Roles/GetRoleInfoRequest.cs
@@ -0,0 +1,10 @@
+namespace PMDDesktop.Requests.Roles;
+
+[ServerRequest("/api/roles/get-info")]
+public sealed class GetRoleInfoRequest : ServerRequest
+{
+
+ [QueryParameter]
+ public required Guid GUID { get; set; }
+
+}
diff --git a/PMDDesktop/Requests/Roles/ListRolesRequest.cs b/PMDDesktop/Requests/Roles/ListRolesRequest.cs
new file mode 100644
index 0000000..168a3bf
--- /dev/null
+++ b/PMDDesktop/Requests/Roles/ListRolesRequest.cs
@@ -0,0 +1,7 @@
+namespace PMDDesktop.Requests.Roles;
+
+[ServerRequest("/api/roles/list")]
+public sealed class ListRolesRequest : ServerRequest
+{
+
+}
diff --git a/PMDDesktop/Requests/Roles/ListRolesResponse.cs b/PMDDesktop/Requests/Roles/ListRolesResponse.cs
new file mode 100644
index 0000000..89f7247
--- /dev/null
+++ b/PMDDesktop/Requests/Roles/ListRolesResponse.cs
@@ -0,0 +1,11 @@
+namespace PMDDesktop.Requests.Roles;
+
+public sealed class ListRolesResponse : ServerResponse
+{
+
+ ///
+ /// All currently existing roles, presented in order.
+ ///
+ public required RoleInfoResponse[] Roles { get; set; }
+
+}
diff --git a/PMDDesktop/Requests/Roles/RoleInfoResponse.cs b/PMDDesktop/Requests/Roles/RoleInfoResponse.cs
new file mode 100644
index 0000000..205436b
--- /dev/null
+++ b/PMDDesktop/Requests/Roles/RoleInfoResponse.cs
@@ -0,0 +1,32 @@
+namespace PMDDesktop.Requests.Roles;
+
+public sealed class RoleInfoResponse : ServerResponse
+{
+
+ ///
+ /// The displayed name of the role.
+ ///
+ public required string DisplayName { get; set; }
+
+ ///
+ /// The of the role.
+ ///
+ public required Guid GUID { get; set; }
+
+ ///
+ /// Dictionary of Permission "data names" (IDs) and whether they're enabled, disabled, or neither.
+ ///
+ public required Dictionary? Permissions { get; set; }
+
+ ///
+ /// The index in which this role is evaluated in the overall role order.
+ /// Starting at 0, a number closer to 0 is evaluated before numbers further from 0, thus a higher-priority.
+ ///
+ public required int IndexInRoleOrder { get; set; }
+
+ ///
+ /// Is this the "default" role that everyone is assigned and can't be assigned or unassigned?
+ ///
+ public required bool IsDefaultRole { get; set; }
+
+}
From e6d7f100291b408cd5479ee92e79d4986577b847 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Thu, 8 Oct 2026 19:29:17 -0500
Subject: [PATCH 47/55] Add endpoint to create a role.
---
.../Roles/CreateRoleRequestHandler.cs | 31 +++++++++++++++++++
.../Requests/Roles/CreateRoleRequest.cs | 14 +++++++++
2 files changed, 45 insertions(+)
create mode 100644 PMDDesktop.Server/RequestHandlers/Roles/CreateRoleRequestHandler.cs
create mode 100644 PMDDesktop/Requests/Roles/CreateRoleRequest.cs
diff --git a/PMDDesktop.Server/RequestHandlers/Roles/CreateRoleRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Roles/CreateRoleRequestHandler.cs
new file mode 100644
index 0000000..1bcab1f
--- /dev/null
+++ b/PMDDesktop.Server/RequestHandlers/Roles/CreateRoleRequestHandler.cs
@@ -0,0 +1,31 @@
+using PMDDesktop.Requests.Roles;
+using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
+using PMDDesktop.Server.Users.Roles;
+
+namespace PMDDesktop.Server.RequestHandlers.Roles;
+
+public class CreateRoleRequestHandler : JsonRequestHandler
+{
+
+ protected override async Task CreateResponse(CreateRoleRequest request, GameServer game, User? requestingUser)
+ {
+
+ if (requestingUser is null)
+ throw new UserRequestException($"You need to be logged in to access this endpoint.");
+
+ // To create roles, you need the MANAGE_ROLES permission.
+ if (!requestingUser.HasPermission(UserPermission.MANAGE_ROLES))
+ throw new UserRequestException($"You need permission to manage roles to access this endpoint.");
+
+ UserRole role = await game.State.Roles.TryCreateRole()
+ ?? throw new Exception($"Unable to create role.");
+
+ if (request.NewName is not null)
+ await role.SetName(request.NewName);
+
+ return GenericResponseCreators.CreateRoleInfoResponse(requestingUser, role);
+
+ }
+
+}
diff --git a/PMDDesktop/Requests/Roles/CreateRoleRequest.cs b/PMDDesktop/Requests/Roles/CreateRoleRequest.cs
new file mode 100644
index 0000000..532a6f3
--- /dev/null
+++ b/PMDDesktop/Requests/Roles/CreateRoleRequest.cs
@@ -0,0 +1,14 @@
+namespace PMDDesktop.Requests.Roles;
+
+
+[ServerRequest("/api/roles/create")]
+public sealed class CreateRoleRequest : ServerRequest
+{
+
+ ///
+ /// Optional name for the new role.
+ /// It will be assigned a default name otherwise.
+ ///
+ public string? NewName { get; set; }
+
+}
From 93e49cc753ea1dc1ab0783684d647243f83e08a1 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Fri, 9 Oct 2026 10:03:18 -0500
Subject: [PATCH 48/55] Add IUserHierarchyComparable
This lets us restrict editing operations to users that have a higher role than another role. This prevents, say, a moderator from changing an administrator's roles.
---
.../IUserHierarchyComparable.cs | 18 +++++++++
PMDDesktop.Server.Users/Roles/UserRole.cs | 4 +-
PMDDesktop.Server.Users/User.cs | 38 ++++++++++++++++++-
3 files changed, 58 insertions(+), 2 deletions(-)
create mode 100644 PMDDesktop.Server.Users/IUserHierarchyComparable.cs
diff --git a/PMDDesktop.Server.Users/IUserHierarchyComparable.cs b/PMDDesktop.Server.Users/IUserHierarchyComparable.cs
new file mode 100644
index 0000000..c600a1b
--- /dev/null
+++ b/PMDDesktop.Server.Users/IUserHierarchyComparable.cs
@@ -0,0 +1,18 @@
+namespace PMDDesktop.Server.Users;
+
+///
+/// Interface to compare if a user has access to certain data.
+/// Lower numeric-values are higher permissions; a user must have a higher permission to edit other users' data.
+///
+public interface IUserHierarchyComparable
+{
+
+ ///
+ /// Returns a number indicating the highest place in the hierarchy this user has.
+ /// 0 should be the highest value obtainable by a normal user. -1 is obtainable to admins only.
+ /// Lower numeric values are higher permissions; this should usually be calculated based on role order.
+ ///
+ /// Number indicating the highest place in the hierarchy this user has.
+ int GetHierarchyPosition();
+
+}
diff --git a/PMDDesktop.Server.Users/Roles/UserRole.cs b/PMDDesktop.Server.Users/Roles/UserRole.cs
index bbfcc9d..e78df7c 100644
--- a/PMDDesktop.Server.Users/Roles/UserRole.cs
+++ b/PMDDesktop.Server.Users/Roles/UserRole.cs
@@ -5,7 +5,7 @@
namespace PMDDesktop.Server.Users.Roles;
[JsonConverter(typeof(UserRoleConverter))]
-public sealed class UserRole
+public sealed class UserRole : IUserHierarchyComparable
{
internal UserRole() { }
@@ -272,4 +272,6 @@ public int GetCurrentOrderIndex()
}
+ public int GetHierarchyPosition() => GetCurrentOrderIndex();
+
}
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index cf379bc..a8c99cf 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -7,7 +7,7 @@
namespace PMDDesktop.Server.Users;
[JsonConverter(typeof(UserConverter))]
-public sealed class User
+public sealed class User : IUserHierarchyComparable
{
///
@@ -646,6 +646,42 @@ public async Task SetAdmin(bool newState)
}
+ ///
+ /// Returns true if this could edit based on , given that it has the correct permissions.
+ /// Before you let an edit happen, ensure the user has both the correct permission and a higher hierarchy.
+ ///
+ /// True if this user is higher than .
+ public bool HigherThan(IUserHierarchyComparable target)
+ {
+
+ return GetHierarchyPosition() > target.GetHierarchyPosition();
+
+ }
+
+ public int GetHierarchyPosition()
+ {
+
+ if (Manager is null)
+ throw new NullReferenceException($"Can't check where {this} is in the hierarchy if {nameof(Manager)} is null!");
+
+ // Checked after Manager because we shouldn't be checking for hierarchy on a detached user anyways.
+ if (IsAdmin)
+ return -1;
+
+ foreach (UserRole role in Manager.RoleManager)
+ {
+
+ if (!HasRole(role))
+ continue;
+
+ return role.GetCurrentOrderIndex();
+
+ }
+
+ throw new InvalidOperationException($"Not sure how this happened, but we didn't find a role that {this} matched with. Not even the default role.");
+
+ }
+
#endregion
}
From 87580b09cd8fcb0dcba08e6df621b98db78fe30b Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Fri, 9 Oct 2026 13:46:03 -0500
Subject: [PATCH 49/55] Block lower users from editing higher users' info.
---
.../RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs | 4 ++++
.../RequestHandlers/Users/SetUserNameRequestHandler.cs | 4 ++++
.../RequestHandlers/Users/SetUserPasswordRequestHandler.cs | 4 ++++
3 files changed, 12 insertions(+)
diff --git a/PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs
index 7ab3bd9..1767f9f 100644
--- a/PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs
+++ b/PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs
@@ -19,8 +19,12 @@ protected override async Task CreateResponse(SetUserLoginHandl
// To change the login handle of someone who isn't yourself, you need the MANAGE_USERS permission.
if (user != requestingUser)
+ {
if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS))
throw new UserRequestException($"You need permission to manage users to access this endpoint.");
+ if (!requestingUser.HigherThan(user))
+ throw new UserRequestException($"You are not high enough in the role hierarchy to perform this operation.");
+ }
// TEMPORARY SOLUTION
// TODO: Replace this with a more elegant solution. I think we should make a new class or struct to hold these types of super normalized strings.
diff --git a/PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs
index c0b961a..4e657ab 100644
--- a/PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs
+++ b/PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs
@@ -19,8 +19,12 @@ protected override async Task CreateResponse(SetUserNameReques
// To change the username of someone who isn't yourself, you need the MANAGE_USERS permission.
if (user != requestingUser)
+ {
if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS))
throw new UserRequestException($"You need permission to manage users to access this endpoint.");
+ if (!requestingUser.HigherThan(user))
+ throw new UserRequestException($"You are not high enough in the role hierarchy to perform this operation.");
+ }
await user.SetName(request.NewName);
diff --git a/PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs
index 3e4fd48..4a174c9 100644
--- a/PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs
+++ b/PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs
@@ -19,8 +19,12 @@ protected override async Task CreateResponse(SetUserPasswordRe
// To change the login handle of someone who isn't yourself, you need the MANAGE_USERS permission.
if (user != requestingUser)
+ {
if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS))
throw new UserRequestException($"You need permission to manage users to access this endpoint.");
+ if (!requestingUser.HigherThan(user))
+ throw new UserRequestException($"You are not high enough in the role hierarchy to perform this operation.");
+ }
await user.SetPassword(request.NewPassword);
From fe42a2e8bb0ef5f4c6385652c2d1a3e3a7e6d438 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Fri, 9 Oct 2026 18:43:42 -0500
Subject: [PATCH 50/55] Add set role name endpoint
---
.../Roles/SetRoleNameRequestHandler.cs | 33 +++++++++++++++++++
.../Requests/Roles/SetRoleNameRequest.cs | 18 ++++++++++
2 files changed, 51 insertions(+)
create mode 100644 PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs
create mode 100644 PMDDesktop/Requests/Roles/SetRoleNameRequest.cs
diff --git a/PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs
new file mode 100644
index 0000000..3bd5c06
--- /dev/null
+++ b/PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs
@@ -0,0 +1,33 @@
+using PMDDesktop.Requests.Roles;
+using PMDDesktop.Requests.Users;
+using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
+using PMDDesktop.Server.Users.Roles;
+
+namespace PMDDesktop.Server.RequestHandlers.Roles;
+
+public class SetRoleNameRequestHandler : JsonRequestHandler
+{
+
+ protected override async Task CreateResponse(SetRoleNameRequest request, GameServer game, User? requestingUser)
+ {
+
+ if (requestingUser is null)
+ throw new UserRequestException($"You need to be logged in to access this endpoint.");
+
+ if (!requestingUser.HasPermission(UserPermission.MANAGE_ROLES))
+ throw new UserRequestException($"You need permission to manage roles to access this endpoint.");
+
+ if (!game.TryGetRole(request.GUID, out UserRole? role))
+ throw new UserRequestException($"{request.GUID} does not match a user.");
+
+ if (!requestingUser.HigherThan(role))
+ throw new UserRequestException($"You are not high enough in the role hierarchy to perform this operation.");
+
+ await role.SetName(request.NewName);
+
+ return GenericResponseCreators.CreateRoleInfoResponse(requestingUser, role);
+
+ }
+
+}
diff --git a/PMDDesktop/Requests/Roles/SetRoleNameRequest.cs b/PMDDesktop/Requests/Roles/SetRoleNameRequest.cs
new file mode 100644
index 0000000..4dcd5a5
--- /dev/null
+++ b/PMDDesktop/Requests/Roles/SetRoleNameRequest.cs
@@ -0,0 +1,18 @@
+namespace PMDDesktop.Requests.Roles;
+
+
+[ServerRequest("/api/roles/set-name")]
+public sealed class SetRoleNameRequest : ServerRequest
+{
+
+ ///
+ /// The of the role you're trying to set the name of.
+ ///
+ public required Guid GUID { get; set; }
+
+ ///
+ /// New name for the new role.
+ ///
+ public required string NewName { get; set; }
+
+}
From 71f0dba9ccd02be8bc025e7ba39cbae3b321cf25 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Fri, 9 Oct 2026 22:21:14 -0500
Subject: [PATCH 51/55] Fix embarrassing loading issues
---
PMDDesktop.Server.Users/Roles/UserRoleManager.cs | 4 +---
PMDDesktop.Server.Users/User.cs | 4 ++--
PMDDesktop.Server.Users/UserManager.cs | 6 +++---
3 files changed, 6 insertions(+), 8 deletions(-)
diff --git a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs
index efef5d4..75dfa14 100644
--- a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs
+++ b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs
@@ -93,9 +93,7 @@ internal async Task LoadAllRoleData()
if (!Guid.TryParse(guidParsable, out Guid loadedGUID))
throw new Exception($"Couldn't parse {guidParsable} as a GUID at {filePath}");
- string userFilePath = Path.Join(filePath, User.USER_FILE_NAME);
-
- using FileStream readStream = File.OpenRead(userFilePath);
+ using FileStream readStream = File.OpenRead(filePath);
await LoadAndAddRoleJson(readStream, loadedGUID);
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index a8c99cf..45692b4 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -77,7 +77,7 @@ internal User()
///
/// Is/Was on the last used or currently active .
///
- public bool? WritingEnabled { get; private set; }
+ public bool? WritingEnabled { get => Manager is not null ? Manager.WritingEnabled : field; private set; }
///
/// Specifies if this user is affected by .
@@ -474,7 +474,6 @@ internal void AttachToManager(UserManager manager)
throw new InvalidOperationException($"This existing value of {nameof(Manager)} is not null. Cannot attach while already attached. Does this call to {nameof(AttachToManager)} need to be skipped, or does {nameof(DetachFromManager)} need to be called first?");
Manager = manager;
- WritingEnabled = manager.WritingEnabled;
Manager.guids.Add(GUID, this);
@@ -508,6 +507,7 @@ internal void DetachFromManager()
foreach (UserAccessToken token in accessTokens)
DetachAccessToken(token);
+ WritingEnabled = Manager.WritingEnabled;
Manager = null;
}
diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs
index 1cfdff9..35a370d 100644
--- a/PMDDesktop.Server.Users/UserManager.cs
+++ b/PMDDesktop.Server.Users/UserManager.cs
@@ -74,13 +74,13 @@ public UserManager()
public async Task LoadFromFilesAndEnableWriting()
{
- // Since Users check the WritingEnabled value, we need to set this before loading.
- WritingEnabled = true;
-
await LoadAllUserData();
await RoleManager.LoadFromFiles();
+ // This needs to be set after loading to prevent premature writing while loading.
+ WritingEnabled = true;
+
}
///
From aa162f6f9174b04db71d03b7bcc00e9c7d3a25fc Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Fri, 9 Oct 2026 23:03:59 -0500
Subject: [PATCH 52/55] Change direction of Hierarchy comparison
---
PMDDesktop.Server.Users/User.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs
index 45692b4..917a83e 100644
--- a/PMDDesktop.Server.Users/User.cs
+++ b/PMDDesktop.Server.Users/User.cs
@@ -654,7 +654,7 @@ public async Task SetAdmin(bool newState)
public bool HigherThan(IUserHierarchyComparable target)
{
- return GetHierarchyPosition() > target.GetHierarchyPosition();
+ return GetHierarchyPosition() < target.GetHierarchyPosition();
}
From 8b775a8144c8dff1d706ead9bdc2931e591d8cf8 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Fri, 9 Oct 2026 23:09:36 -0500
Subject: [PATCH 53/55] Change typo of 'user' to 'role.'
---
.../RequestHandlers/Roles/SetRoleNameRequestHandler.cs | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs
index 3bd5c06..e6d72bd 100644
--- a/PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs
+++ b/PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs
@@ -1,5 +1,4 @@
using PMDDesktop.Requests.Roles;
-using PMDDesktop.Requests.Users;
using PMDDesktop.Server.Game;
using PMDDesktop.Server.Users;
using PMDDesktop.Server.Users.Roles;
@@ -19,7 +18,7 @@ protected override async Task CreateResponse(SetRoleNameReques
throw new UserRequestException($"You need permission to manage roles to access this endpoint.");
if (!game.TryGetRole(request.GUID, out UserRole? role))
- throw new UserRequestException($"{request.GUID} does not match a user.");
+ throw new UserRequestException($"{request.GUID} does not match a role.");
if (!requestingUser.HigherThan(role))
throw new UserRequestException($"You are not high enough in the role hierarchy to perform this operation.");
From aa3ffe40b9f845c731b7c052845a70e601e4d708 Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Fri, 9 Oct 2026 23:10:01 -0500
Subject: [PATCH 54/55] Add endpoint to set role permissions.
---
.../Roles/UserPermission.cs | 20 +++++++++++
.../Roles/SetRolePermissionRequestHandler.cs | 35 +++++++++++++++++++
.../Roles/SetRolePermissionRequest.cs | 24 +++++++++++++
3 files changed, 79 insertions(+)
create mode 100644 PMDDesktop.Server/RequestHandlers/Roles/SetRolePermissionRequestHandler.cs
create mode 100644 PMDDesktop/Requests/Roles/SetRolePermissionRequest.cs
diff --git a/PMDDesktop.Server.Users/Roles/UserPermission.cs b/PMDDesktop.Server.Users/Roles/UserPermission.cs
index 83d9bb8..4469f61 100644
--- a/PMDDesktop.Server.Users/Roles/UserPermission.cs
+++ b/PMDDesktop.Server.Users/Roles/UserPermission.cs
@@ -1,4 +1,5 @@
using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
using System.Reflection;
namespace PMDDesktop.Server.Users.Roles;
@@ -21,6 +22,25 @@ static UserPermission()
}
+ public static bool TryGetPermissionByDataName(string dataName, [NotNullWhen(true)] out UserPermission? permission)
+ {
+
+ foreach (UserPermission checking in ALL)
+ {
+
+ if (checking.DataName != dataName)
+ continue;
+
+ permission = checking;
+ return true;
+
+ }
+
+ permission = null;
+ return false;
+
+ }
+
///
/// Array containing all objects. (Should not change after initial creation.)
///
diff --git a/PMDDesktop.Server/RequestHandlers/Roles/SetRolePermissionRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Roles/SetRolePermissionRequestHandler.cs
new file mode 100644
index 0000000..da5412b
--- /dev/null
+++ b/PMDDesktop.Server/RequestHandlers/Roles/SetRolePermissionRequestHandler.cs
@@ -0,0 +1,35 @@
+using PMDDesktop.Requests.Roles;
+using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
+using PMDDesktop.Server.Users.Roles;
+
+namespace PMDDesktop.Server.RequestHandlers.Roles;
+
+public class SetRolePermissionRequestHandler : JsonRequestHandler
+{
+
+ protected override async Task CreateResponse(SetRolePermissionRequest request, GameServer game, User? requestingUser)
+ {
+
+ if (requestingUser is null)
+ throw new UserRequestException($"You need to be logged in to access this endpoint.");
+
+ if (!requestingUser.HasPermission(UserPermission.MANAGE_ROLES))
+ throw new UserRequestException($"You need permission to manage roles to access this endpoint.");
+
+ if (!game.TryGetRole(request.GUID, out UserRole? role))
+ throw new UserRequestException($"{request.GUID} does not match a role.");
+
+ if (!requestingUser.HigherThan(role))
+ throw new UserRequestException($"You are not high enough in the role hierarchy to perform this operation.");
+
+ if (!UserPermission.TryGetPermissionByDataName(request.PermissionDataName, out UserPermission? permission))
+ throw new UserRequestException($"Could not find a permission with the name '{request.PermissionDataName}' to set.");
+
+ await role.SetPermission(permission, request.NewPermissionValue);
+
+ return GenericResponseCreators.CreateRoleInfoResponse(requestingUser, role);
+
+ }
+
+}
diff --git a/PMDDesktop/Requests/Roles/SetRolePermissionRequest.cs b/PMDDesktop/Requests/Roles/SetRolePermissionRequest.cs
new file mode 100644
index 0000000..65deb6a
--- /dev/null
+++ b/PMDDesktop/Requests/Roles/SetRolePermissionRequest.cs
@@ -0,0 +1,24 @@
+namespace PMDDesktop.Requests.Roles;
+
+[ServerRequest("/api/roles/set-permission")]
+public sealed class SetRolePermissionRequest : ServerRequest
+{
+
+ ///
+ /// The of the role you're trying to edit.
+ ///
+ public required Guid GUID { get; set; }
+
+ ///
+ /// The "data name" of the permission you're trying to edit.
+ ///
+ public required string PermissionDataName { get; set; }
+
+ ///
+ /// The value you'd like to set the permission to.
+ /// True allows this permission for this role, false denies it, and null makes the role not affect this permission.
+ /// When evaluating permissions for a user, they check the highest role they have assigned with a non-null permission value for that permission.
+ ///
+ public required bool? NewPermissionValue { get; set; }
+
+}
From e061b4d2d25f7c6d87833070ed749e8000b98eac Mon Sep 17 00:00:00 2001
From: Weffuls <111707520+Weffuls@users.noreply.github.com>
Date: Tue, 13 Oct 2026 08:31:23 -0500
Subject: [PATCH 55/55] Add endpoint to list all user permissions
---
.../GenericResponseCreators.cs | 15 ++++++++++++-
.../ListPermissionsRequestHandler.cs | 21 +++++++++++++++++++
.../Permissions/ListPermissionsRequest.cs | 7 +++++++
.../Permissions/ListPermissionsResponse.cs | 11 ++++++++++
.../Permissions/PermissionInfoResponse.cs | 21 +++++++++++++++++++
5 files changed, 74 insertions(+), 1 deletion(-)
create mode 100644 PMDDesktop.Server/RequestHandlers/Permissions/ListPermissionsRequestHandler.cs
create mode 100644 PMDDesktop/Requests/Permissions/ListPermissionsRequest.cs
create mode 100644 PMDDesktop/Requests/Permissions/ListPermissionsResponse.cs
create mode 100644 PMDDesktop/Requests/Permissions/PermissionInfoResponse.cs
diff --git a/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs b/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs
index b54f458..d7f712f 100644
--- a/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs
+++ b/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs
@@ -1,4 +1,5 @@
-using PMDDesktop.Requests.Roles;
+using PMDDesktop.Requests.Permissions;
+using PMDDesktop.Requests.Roles;
using PMDDesktop.Requests.Users;
using PMDDesktop.Server.Users;
using PMDDesktop.Server.Users.Roles;
@@ -60,4 +61,16 @@ internal static RoleInfoResponse CreateRoleInfoResponse(User fromPOV, UserRole o
}
+ internal static PermissionInfoResponse CreatePermissionInfoResponse(UserPermission ofPermission)
+ {
+
+ return new()
+ {
+ DataName = ofPermission.DataName,
+ FriendlyDescription = ofPermission.FriendlyDescription,
+ FriendlyName = ofPermission.FriendlyName
+ };
+
+ }
+
}
diff --git a/PMDDesktop.Server/RequestHandlers/Permissions/ListPermissionsRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Permissions/ListPermissionsRequestHandler.cs
new file mode 100644
index 0000000..fafd792
--- /dev/null
+++ b/PMDDesktop.Server/RequestHandlers/Permissions/ListPermissionsRequestHandler.cs
@@ -0,0 +1,21 @@
+using PMDDesktop.Requests.Permissions;
+using PMDDesktop.Server.Game;
+using PMDDesktop.Server.Users;
+using PMDDesktop.Server.Users.Roles;
+
+namespace PMDDesktop.Server.RequestHandlers.Permissions;
+
+public class ListPermissionsRequestHandler : JsonRequestHandler
+{
+
+ protected override async Task CreateResponse(ListPermissionsRequest request, GameServer game, User? requestingUser)
+ {
+
+ return new()
+ {
+ Permissions = [.. UserPermission.ALL.Select(permission => GenericResponseCreators.CreatePermissionInfoResponse(permission))]
+ };
+
+ }
+
+}
diff --git a/PMDDesktop/Requests/Permissions/ListPermissionsRequest.cs b/PMDDesktop/Requests/Permissions/ListPermissionsRequest.cs
new file mode 100644
index 0000000..bce7379
--- /dev/null
+++ b/PMDDesktop/Requests/Permissions/ListPermissionsRequest.cs
@@ -0,0 +1,7 @@
+namespace PMDDesktop.Requests.Permissions;
+
+[ServerRequest("/api/permissions/list")]
+public sealed class ListPermissionsRequest : ServerRequest
+{
+
+}
diff --git a/PMDDesktop/Requests/Permissions/ListPermissionsResponse.cs b/PMDDesktop/Requests/Permissions/ListPermissionsResponse.cs
new file mode 100644
index 0000000..c88d2a8
--- /dev/null
+++ b/PMDDesktop/Requests/Permissions/ListPermissionsResponse.cs
@@ -0,0 +1,11 @@
+namespace PMDDesktop.Requests.Permissions;
+
+public sealed class ListPermissionsResponse : ServerResponse
+{
+
+ ///
+ /// All currently existing roles, presented in order.
+ ///
+ public required PermissionInfoResponse[] Permissions { get; set; }
+
+}
diff --git a/PMDDesktop/Requests/Permissions/PermissionInfoResponse.cs b/PMDDesktop/Requests/Permissions/PermissionInfoResponse.cs
new file mode 100644
index 0000000..eafbdae
--- /dev/null
+++ b/PMDDesktop/Requests/Permissions/PermissionInfoResponse.cs
@@ -0,0 +1,21 @@
+namespace PMDDesktop.Requests.Permissions;
+
+public sealed class PermissionInfoResponse : ServerResponse
+{
+
+ ///
+ /// This is the name used to identify this permission. Should be sent back exactly when talking about this permission with the server.
+ ///
+ public required string DataName { get; set; }
+
+ ///
+ /// The name that's best shown to users.
+ ///
+ public required string FriendlyName { get; set; }
+
+ ///
+ /// A description explaining what the permission does.
+ ///
+ public required string FriendlyDescription { get; set; }
+
+}