From 34506e82949011fabcf79c3830733ee4d51b2098 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 31 May 2026 20:52:11 +0300 Subject: [PATCH] admin: change a player's username from the Players panel Add POST players/{id}/username + a Username field in the player detail's Profile tab. Changes only the unique @handle (not the display name) and rejects a collision outright rather than auto-suffixing, so the admin gets the exact handle they typed. Same 2-24 char letters/digits/_/- rule as the account API; logged to the audit trail as old -> new. --- .../Controllers/Admin/AdminController.cs | 29 +++++++++++++++++++ DorkNet.Server/admin-ui/src/pages/Players.tsx | 14 +++++++++ 2 files changed, 43 insertions(+) diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index 929fb1d..45631eb 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -1725,6 +1725,35 @@ public class AdminController( return Ok(new { p.Id, p.DisplayName }); } + public sealed record UsernameRequest(string Username); + + /// Force-set a player's username (the unique @handle). + /// Unlike the normal account flow this changes ONLY the username, not + /// the display name, and rejects a collision outright rather than + /// auto-suffixing — the admin types the exact handle they want. + /// Username rules mirror the account API: 2–24 chars, letters / + /// digits / underscore / hyphen. + [HttpPost("players/{id:long}/username")] + public async Task SetUsername(long id, [FromBody] UsernameRequest body) + { + var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id); + if (p is null) return NotFound(); + + var name = (body.Username ?? string.Empty).Trim(); + if (name.Length is < 2 or > 24 + || !name.All(ch => char.IsLetterOrDigit(ch) || ch is '_' or '-')) + return BadRequest(new { Error = "Username must be 2–24 chars: letters, numbers, _ or -." }); + + if (await db.Players.AnyAsync(x => x.Username == name && x.Id != id)) + return BadRequest(new { Error = "That username is already taken." }); + + var previous = p.Username; + p.Username = name; + await LogAsync("set_username", "player", id, $"{previous} -> {name}"); + await db.SaveChangesAsync(); + return Ok(new { p.Id, p.Username }); + } + private sealed class InventoryEntry { [System.Text.Json.Serialization.JsonPropertyName("itemId")] diff --git a/DorkNet.Server/admin-ui/src/pages/Players.tsx b/DorkNet.Server/admin-ui/src/pages/Players.tsx index ead0beb..c616ef9 100644 --- a/DorkNet.Server/admin-ui/src/pages/Players.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Players.tsx @@ -446,6 +446,7 @@ function GrantsTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => v function ProfileTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => void }) { const toast = useToast(); const [displayName, setDisplayName] = useState(data.displayName); + const [username, setUsername] = useState(data.username); const [flags, setFlags] = useState({ isVerified: data.isVerified, isDeveloper: data.isDeveloper, @@ -465,6 +466,19 @@ function ProfileTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => return (
+
+

Username

+

The unique @handle. 2–24 chars: letters, numbers, _ or -. Changing it doesn't touch the display name.

+
+ setUsername(e.target.value)} className="input flex-1 font-mono text-xs" /> + +
+
+

Display name

Force-override the player's display name.