mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
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.
This commit is contained in:
@@ -70,6 +70,24 @@ public class Mod : MelonMod
|
|||||||
// Photon Custom Auth injector parked 2026-05-28; see
|
// Photon Custom Auth injector parked 2026-05-28; see
|
||||||
// attic/AuthValuesInjector.cs.attic for the code + restore notes.
|
// attic/AuthValuesInjector.cs.attic for the code + restore notes.
|
||||||
public static bool EnableTlsTrustBypass = true;
|
public static bool EnableTlsTrustBypass = true;
|
||||||
|
// Treat the local account as already fully registered so the
|
||||||
|
// first-dorm-entry registration flow never runs. That flow
|
||||||
|
// (DormroomSceneManager → PromptForRegistration) shows two
|
||||||
|
// back-to-back watch dialogs: first "Your username is @<auto-gen
|
||||||
|
// name> — want to change it?" (which broadcasts the throwaway
|
||||||
|
// auto-generated handle and makes the account look brand-new),
|
||||||
|
// then the "…permanently lost without a password…" warning whose
|
||||||
|
// confirm button never actually dismisses the modal on this
|
||||||
|
// build, hard-locking the player in the dorm. Both are gated on
|
||||||
|
// RRUI.Data.NUX.RegistrationModel.IsFullyRegistered(): when it
|
||||||
|
// returns true the watch jumps clean past the whole block
|
||||||
|
// (verified in DormroomSceneManager_NestedType_FBDKOJHAJMJ /
|
||||||
|
// ___c ISIL — `Call RegistrationModel.IsFullyRegistered` →
|
||||||
|
// `Compare rax,0` → `JumpIfNotEqual` skips the
|
||||||
|
// ShowConfirmationDialog calls). Forcing it true means new
|
||||||
|
// players drop straight into their dorm with no brand-new nag
|
||||||
|
// and no exposed auto-username. See RegistrationPatches.
|
||||||
|
public static bool TreatAccountAsRegistered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnInitializeMelon()
|
public override void OnInitializeMelon()
|
||||||
@@ -217,6 +235,25 @@ public class Mod : MelonMod
|
|||||||
args: new[] { typeof(string) },
|
args: new[] { typeof(string) },
|
||||||
prefix: nameof(ChatPatches.RpcChatEmote_Prefix));
|
prefix: nameof(ChatPatches.RpcChatEmote_Prefix));
|
||||||
|
|
||||||
|
// Skip the first-dorm "Change Username?" registration flow for
|
||||||
|
// brand-new accounts. DormroomSceneManager raises two dialogs the
|
||||||
|
// first time a not-fully-registered account enters its dorm — one
|
||||||
|
// that prints the auto-generated @username and one warning whose
|
||||||
|
// confirm button doesn't dismiss the modal on this build, leaving
|
||||||
|
// the player stuck. Both are gated on
|
||||||
|
// RRUI.Data.NUX.RegistrationModel.IsFullyRegistered(); forcing it
|
||||||
|
// true makes the watch skip the entire block, so new players land
|
||||||
|
// in their dorm without the brand-new nag or the exposed
|
||||||
|
// auto-username. See Cfg.TreatAccountAsRegistered for the ISIL
|
||||||
|
// evidence.
|
||||||
|
if (Cfg.TreatAccountAsRegistered)
|
||||||
|
{
|
||||||
|
TryPatchByName("RRUI.Data.NUX.RegistrationModel",
|
||||||
|
"IsFullyRegistered",
|
||||||
|
args: Type.EmptyTypes,
|
||||||
|
postfix: nameof(RegistrationPatches.IsFullyRegistered_Postfix));
|
||||||
|
}
|
||||||
|
|
||||||
RegisterJoinTracePatches();
|
RegisterJoinTracePatches();
|
||||||
|
|
||||||
RegisterDiagnostics();
|
RegisterDiagnostics();
|
||||||
@@ -461,6 +498,7 @@ public class Mod : MelonMod
|
|||||||
if (r.TryGetProperty("PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId;
|
if (r.TryGetProperty("PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId;
|
||||||
if (r.TryGetProperty("PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion;
|
if (r.TryGetProperty("PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion;
|
||||||
if (r.TryGetProperty("EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
|
if (r.TryGetProperty("EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
|
||||||
|
if (r.TryGetProperty("TreatAccountAsRegistered", out v)) Cfg.TreatAccountAsRegistered = v.GetBoolean();
|
||||||
// "InjectAuthValues" key in the template is now ignored —
|
// "InjectAuthValues" key in the template is now ignored —
|
||||||
// see attic/AuthValuesInjector.cs.attic.
|
// see attic/AuthValuesInjector.cs.attic.
|
||||||
Log.Msg($"[config] loaded: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " +
|
Log.Msg($"[config] loaded: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " +
|
||||||
@@ -611,7 +649,7 @@ public class Mod : MelonMod
|
|||||||
{
|
{
|
||||||
// Look in all three patch holder classes — small enough that a
|
// Look in all three patch holder classes — small enough that a
|
||||||
// linear scan is cheaper than per-class lookups.
|
// linear scan is cheaper than per-class lookups.
|
||||||
foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches) })
|
foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches) })
|
||||||
{
|
{
|
||||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||||
if (m is not null) return m;
|
if (m is not null) return m;
|
||||||
@@ -1453,6 +1491,31 @@ internal static class ChatPatches
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registration-prompt suppression. See the registration site in
|
||||||
|
// OnLateInitializeMelon and Cfg.TreatAccountAsRegistered for the full
|
||||||
|
// rationale and the IL2CPP evidence.
|
||||||
|
internal static class RegistrationPatches
|
||||||
|
{
|
||||||
|
private static bool s_logged;
|
||||||
|
|
||||||
|
// Postfix on RRUI.Data.NUX.RegistrationModel.IsFullyRegistered().
|
||||||
|
// DormroomSceneManager checks this before raising either first-dorm
|
||||||
|
// "Change Username?" dialog; returning true makes the watch jump past
|
||||||
|
// the whole prompt block, so a brand-new account never gets the
|
||||||
|
// username-reveal prompt or the stuck "permanently lost" warning and
|
||||||
|
// drops straight into its dorm. Players who do want to set a username
|
||||||
|
// and password can still do it from the profile section of the watch.
|
||||||
|
public static void IsFullyRegistered_Postfix(ref bool __result)
|
||||||
|
{
|
||||||
|
if (!__result && !s_logged)
|
||||||
|
{
|
||||||
|
s_logged = true;
|
||||||
|
Mod.Log.Msg("[registration-fix] RegistrationModel.IsFullyRegistered returned false; forcing true so the first-dorm 'Change Username?' prompt is skipped");
|
||||||
|
}
|
||||||
|
__result = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Trace hooks for the watch's room-join flow. Registered by
|
// Trace hooks for the watch's room-join flow. Registered by
|
||||||
// Mod.RegisterJoinTracePatches — see that method for the verified call
|
// Mod.RegisterJoinTracePatches — see that method for the verified call
|
||||||
// chain. Everything here is log-only; no prefix returns false, so the
|
// chain. Everything here is log-only; no prefix returns false, so the
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
|||||||
.Select(a => new { a.InventoryJson, a.OutfitSelections })
|
.Select(a => new { a.InventoryJson, a.OutfitSelections })
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
var owned = ParseInventory(avatar?.InventoryJson);
|
var owned = ParseInventoryEntries(avatar?.InventoryJson);
|
||||||
var ownedHairDyeSlugs = await db.PlayerInventory
|
var ownedHairDyeSlugs = await db.PlayerInventory
|
||||||
.Where(i => i.PlayerId == me && i.Quantity > 0)
|
.Where(i => i.PlayerId == me && i.Quantity > 0)
|
||||||
.Select(i => i.ItemSlug)
|
.Select(i => i.ItemSlug)
|
||||||
@@ -123,29 +123,48 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
|||||||
// we want it to drop the OLDEST (starter seed) items rather than the
|
// we want it to drop the OLDEST (starter seed) items rather than the
|
||||||
// one the user just bought.
|
// one the user just bought.
|
||||||
var ordered = owned
|
var ordered = owned
|
||||||
.Select((guid, idx) => (Guid: guid, OriginalIndex: idx))
|
.Select((entry, idx) => (Entry: entry, OriginalIndex: idx))
|
||||||
.OrderByDescending(x => equipped.Contains(x.Guid))
|
.OrderByDescending(x => equipped.Contains(x.Entry.BaseGuid))
|
||||||
.ThenBy(x => catalog.SlotByGuid.TryGetValue(x.Guid, out var slot) ? slot : int.MaxValue)
|
.ThenBy(x => catalog.SlotByGuid.TryGetValue(x.Entry.BaseGuid, out var slot) ? slot : int.MaxValue)
|
||||||
.ThenByDescending(x => x.OriginalIndex)
|
.ThenByDescending(x => x.OriginalIndex)
|
||||||
.Select(x => x.Guid);
|
.Select(x => x.Entry);
|
||||||
|
|
||||||
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count);
|
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count);
|
||||||
var perSlot = new Dictionary<int, int>();
|
var perSlot = new Dictionary<int, int>();
|
||||||
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
|
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
|
||||||
foreach (var guid in ordered)
|
foreach (var entry in ordered)
|
||||||
{
|
{
|
||||||
if (result.Count >= maxOutfitItems) break;
|
if (result.Count >= maxOutfitItems) break;
|
||||||
// Intersect against the safelist. Items not in the bundled
|
// Intersect against the safelist. Items not in the bundled
|
||||||
// lookup would crash the watch's wardrobe parser, so they're
|
// lookup would crash the watch's wardrobe parser, so they're
|
||||||
// silently dropped here.
|
// silently dropped here. The catalog is keyed by the BARE GUID,
|
||||||
if (!catalog.Items.TryGetValue(guid, out var item)) continue;
|
// so colored variants (stored as "{guid},{swatch},{mask},") must
|
||||||
|
// look up by BaseGuid — keying by the full comma string would
|
||||||
|
// miss and silently drop every owned colored item (the contest
|
||||||
|
// hat "I bought it but it never shows in my wardrobe" bug).
|
||||||
|
if (!catalog.Items.TryGetValue(entry.BaseGuid, out var item)) continue;
|
||||||
|
|
||||||
var slot = catalog.SlotByGuid.TryGetValue(guid, out var value) ? value : -1;
|
var slot = catalog.SlotByGuid.TryGetValue(entry.BaseGuid, out var value) ? value : -1;
|
||||||
perSlot.TryGetValue(slot, out var count);
|
perSlot.TryGetValue(slot, out var count);
|
||||||
if (count >= maxPerSlot && !equipped.Contains(guid)) continue;
|
if (count >= maxPerSlot && !equipped.Contains(entry.BaseGuid)) continue;
|
||||||
|
|
||||||
perSlot[slot] = count + 1;
|
perSlot[slot] = count + 1;
|
||||||
result.Add(item);
|
// Preserve the bought color: a bare entry (FullDesc == BaseGuid)
|
||||||
|
// emits the catalog DTO as-is ("{guid},,,"); a colored entry
|
||||||
|
// overrides AvatarItemDesc with the stored swatch/mask desc so
|
||||||
|
// the watch renders the variant the player paid for instead of
|
||||||
|
// falling back to the default swatch.
|
||||||
|
result.Add(string.Equals(entry.FullDesc, entry.BaseGuid, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? item
|
||||||
|
: new UnlockedAvatarItemDto
|
||||||
|
{
|
||||||
|
AvatarItemType = item.AvatarItemType,
|
||||||
|
AvatarItemDesc = ToWatchDesc(entry.FullDesc),
|
||||||
|
PlatformMask = item.PlatformMask,
|
||||||
|
FriendlyName = item.FriendlyName,
|
||||||
|
Tooltip = item.Tooltip,
|
||||||
|
Rarity = item.Rarity,
|
||||||
|
});
|
||||||
if (result.Count >= maxItems) break;
|
if (result.Count >= maxItems) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,47 +267,89 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<string> ParseInventory(string? json)
|
/// <summary>An owned wardrobe entry: the bare AvatarItem GUID used as
|
||||||
|
/// the catalog / slot / equipped key, plus the full RecNet desc exactly
|
||||||
|
/// as stored in InventoryJson. For a plain item the two are identical;
|
||||||
|
/// for a colored variant the desc is "{guid},{swatch},{mask}," so the
|
||||||
|
/// swatch the player paid for survives the round-trip to the wardrobe.</summary>
|
||||||
|
private readonly record struct OwnedEntry(string BaseGuid, string FullDesc);
|
||||||
|
|
||||||
|
/// <summary>Parse InventoryJson into normalized owned entries. Accepts
|
||||||
|
/// both the plain JSON string array of descs (the shape
|
||||||
|
/// GrantWardrobeAsync / PurchaseAsync write) and the legacy
|
||||||
|
/// [{itemId,...}] object shape. Strips a leading "wardrobe-" slug
|
||||||
|
/// prefix, derives the bare GUID (substring before the first comma) for
|
||||||
|
/// catalog lookups, and keeps the full desc so a colored variant keeps
|
||||||
|
/// its swatch. Dedupes by full desc, so multiple colors of the same
|
||||||
|
/// base item remain distinct wardrobe tiles while exact duplicates
|
||||||
|
/// collapse.</summary>
|
||||||
|
private static List<OwnedEntry> ParseInventoryEntries(string? json)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
|
||||||
|
|
||||||
|
var raw = new List<string>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var strings = JsonSerializer.Deserialize<List<string>>(json);
|
var strings = JsonSerializer.Deserialize<List<string>>(json);
|
||||||
if (strings is not null) return strings;
|
if (strings is not null) raw.AddRange(strings);
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
|
|
||||||
try
|
if (raw.Count == 0)
|
||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(json);
|
try
|
||||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
|
|
||||||
|
|
||||||
var result = new List<string>();
|
|
||||||
foreach (var element in doc.RootElement.EnumerateArray())
|
|
||||||
{
|
{
|
||||||
string? value = null;
|
using var doc = JsonDocument.Parse(json);
|
||||||
if (element.ValueKind == JsonValueKind.String)
|
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
|
||||||
value = element.GetString();
|
foreach (var element in doc.RootElement.EnumerateArray())
|
||||||
else if (element.ValueKind == JsonValueKind.Object &&
|
{
|
||||||
element.TryGetProperty("itemId", out var itemId) &&
|
if (element.ValueKind == JsonValueKind.String)
|
||||||
itemId.ValueKind == JsonValueKind.String)
|
raw.Add(element.GetString() ?? string.Empty);
|
||||||
value = itemId.GetString();
|
else if (element.ValueKind == JsonValueKind.Object &&
|
||||||
|
element.TryGetProperty("itemId", out var itemId) &&
|
||||||
if (string.IsNullOrWhiteSpace(value)) continue;
|
itemId.ValueKind == JsonValueKind.String)
|
||||||
const string wardrobePrefix = "wardrobe-";
|
raw.Add(itemId.GetString() ?? string.Empty);
|
||||||
if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal))
|
}
|
||||||
value = value[wardrobePrefix.Length..];
|
|
||||||
var comma = value.IndexOf(',');
|
|
||||||
if (comma >= 0)
|
|
||||||
value = value[..comma];
|
|
||||||
result.Add(value);
|
|
||||||
}
|
}
|
||||||
|
catch { return new(); }
|
||||||
return result
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToList();
|
|
||||||
}
|
}
|
||||||
catch { return new(); }
|
|
||||||
|
const string wardrobePrefix = "wardrobe-";
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var result = new List<OwnedEntry>();
|
||||||
|
foreach (var entry in raw)
|
||||||
|
{
|
||||||
|
var value = entry;
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) continue;
|
||||||
|
if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal))
|
||||||
|
value = value[wardrobePrefix.Length..];
|
||||||
|
var comma = value.IndexOf(',');
|
||||||
|
var baseGuid = comma >= 0 ? value[..comma] : value;
|
||||||
|
if (string.IsNullOrWhiteSpace(baseGuid)) continue;
|
||||||
|
if (!seen.Add(value)) continue;
|
||||||
|
result.Add(new OwnedEntry(baseGuid, value));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bare-GUID view of the owned set, for callers that only need
|
||||||
|
/// ownership membership (e.g. the locked-items diff), not the swatch.</summary>
|
||||||
|
private static List<string> ParseInventory(string? json) =>
|
||||||
|
ParseInventoryEntries(json)
|
||||||
|
.Select(e => e.BaseGuid)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
/// <summary>Ensure a stored desc carries the ≥4 comma-separated parts
|
||||||
|
/// (guid + 3 customizations) that the watch's
|
||||||
|
/// <c>AvatarItem.FromRecNetString</c> requires — anything shorter throws
|
||||||
|
/// inside the wardrobe parser. Stored colored descs are already
|
||||||
|
/// "{guid},{swatch},{mask}," (3 commas); this pads any shorter legacy
|
||||||
|
/// value.</summary>
|
||||||
|
private static string ToWatchDesc(string storedDesc)
|
||||||
|
{
|
||||||
|
var commas = storedDesc.Count(c => c == ',');
|
||||||
|
return commas >= 3 ? storedDesc : storedDesc + new string(',', 3 - commas);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static HashSet<string> ParseEquippedItemGuids(string? outfitSelections)
|
private static HashSet<string> ParseEquippedItemGuids(string? outfitSelections)
|
||||||
|
|||||||
@@ -1953,6 +1953,35 @@ public class AdminController(
|
|||||||
return Ok(new { p.Id, p.DisplayName });
|
return Ok(new { p.Id, p.DisplayName });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record UsernameRequest(string Username);
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
[HttpPost("players/{id:long}/username")]
|
||||||
|
public async Task<ActionResult> 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
|
private sealed class InventoryEntry
|
||||||
{
|
{
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("itemId")]
|
[System.Text.Json.Serialization.JsonPropertyName("itemId")]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
matches 239 | unmatched 319 | ambiguous 22
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Traceback (most recent call last):
|
||||||
|
File "<string>", line 1, in <module>
|
||||||
|
import json; d=json.load(open('color_variants.json')); [print(repr(v.get('baseName')),'outfitType=',v.get('outfitType'),'colors=',len(v.get('colors',[]))) for v in d['variants']]
|
||||||
|
~^^^^^^^^^^^^
|
||||||
|
KeyError: 'variants'
|
||||||
|
total items 530
|
||||||
|
Counter({None: 530})
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
total 530
|
||||||
|
outfitType None = 530
|
||||||
@@ -457,6 +457,7 @@ function GrantsTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => v
|
|||||||
function ProfileTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => void }) {
|
function ProfileTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => void }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [displayName, setDisplayName] = useState(data.displayName);
|
const [displayName, setDisplayName] = useState(data.displayName);
|
||||||
|
const [username, setUsername] = useState(data.username);
|
||||||
const [flags, setFlags] = useState({
|
const [flags, setFlags] = useState({
|
||||||
isVerified: data.isVerified,
|
isVerified: data.isVerified,
|
||||||
isDeveloper: data.isDeveloper,
|
isDeveloper: data.isDeveloper,
|
||||||
@@ -476,6 +477,19 @@ function ProfileTab({ data, onChanged }: { data: PlayerDetail; onChanged: () =>
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div className="card !p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-ink-50 mb-1">Username</h3>
|
||||||
|
<p className="text-xs text-ink-400 mb-3">The unique @handle. 2–24 chars: letters, numbers, _ or -. Changing it doesn't touch the display name.</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input value={username} onChange={e => setUsername(e.target.value)} className="input flex-1 font-mono text-xs" />
|
||||||
|
<button
|
||||||
|
onClick={() => save('Username', () => api(`/players/${data.id}/username`, { method: 'POST', body: { Username: username.trim() } }))}
|
||||||
|
disabled={!username.trim() || username.trim() === data.username}
|
||||||
|
className="btn-primary text-xs"
|
||||||
|
>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card !p-4">
|
<div className="card !p-4">
|
||||||
<h3 className="text-sm font-semibold text-ink-50 mb-1">Display name</h3>
|
<h3 className="text-sm font-semibold text-ink-50 mb-1">Display name</h3>
|
||||||
<p className="text-xs text-ink-400 mb-3">Force-override the player's display name.</p>
|
<p className="text-xs text-ink-400 mb-3">Force-override the player's display name.</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user