feat(client): account usernames + verified badge on player-facing lists (#4653)

## Summary

Client side of openfrontio/infra#436 — every player-facing GET now
returns the player's **account username** next to their `publicId`, and
this PR renders it everywhere, with a blue verified check for
premium/indefinite bare-name holders.

### Schemas (all additive, `.nullable().optional()` — no deploy-order
constraint)
- `FriendEntry`, `PlayerProfile`: `username`
- `ClanMember`, `ClanJoinRequest`: `username`; `ClanBan`: `username` +
`bannedByUsername`
- `RankedLeaderboardEntry`: `accountUsername` (the existing session
`username` field is untouched)

### `<player-name>` element
One component owns player-identity rendering on account surfaces:
- displays `username ?? publicId`
- click-to-copy copies the **username when set**, the publicId
otherwise; `copyText` overrides (profile modal copies the share URL)
- `onNameClick` replaces copying with an action (clan member rows +
leaderboard rows open the player's profile); `nameClass` overrides the
chip styling so leaderboard names keep their original bold look
- appends the verified check (same mark as the username-row toggle) when
`isVerifiedUsername(username)`

Used by: friends list + requests, all clan member/request/ban rows (bans
badge both the banned player and the issuing officer), ranked
leaderboard rows + sticky self row (showing `accountUsername ??
public_id` — the session name is deliberately dropped ahead of its API
removal), profile modal header.

### Verified inference
No API flag needed: account-username bases can never contain dots, so a
dotless display name **is** the bare-name claim (premium/indefinite).
`TEMPORARY####` server renames are excluded, matching the play-toggle
eligibility rule. The leaderboard derives the badge from
`accountUsername` only — session names are free-form and would
false-positive.

Also: clan member/request/ban search filters now match usernames, not
just publicIds, and sending a friend request refetches the request lists
instead of inserting the raw input (which may be a username, not a
publicId).

## Test plan
- [x] `npm test` — full suite green (2087 + 176 server)
- [x] New schema tests: each field's set / null / absent states;
`accountUsername` coexists with session `username`
- [x] `isVerifiedUsername` unit tests (bare / suffixed / null /
TEMPORARY)
- [x] tsc, ESLint, Prettier clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-20 14:15:10 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent ad8d7e995a
commit 826d8b1bcf
16 changed files with 433 additions and 102 deletions
+6 -2
View File
@@ -241,6 +241,7 @@ describe("LeaderboardModal", () => {
total: 10,
public_id: "player-1",
username: "Alpha",
accountUsername: "alpha.4821",
clanTag: "[AAA]",
},
{
@@ -252,6 +253,7 @@ describe("LeaderboardModal", () => {
total: 10,
public_id: "player-2",
username: "Bravo",
accountUsername: null,
clanTag: null,
},
],
@@ -269,7 +271,7 @@ describe("LeaderboardModal", () => {
expect(playerData[0]).toEqual(
expect.objectContaining({
playerId: "player-1",
username: "Alpha",
accountUsername: "alpha.4821",
clanTag: "[AAA]",
elo: 1200,
games: 10,
@@ -278,10 +280,12 @@ describe("LeaderboardModal", () => {
winRate: 0.6,
}),
);
// The session username ("Bravo") is deliberately ignored — display
// falls back to the playerId when no account username is set.
expect(playerData[1]).toEqual(
expect.objectContaining({
playerId: "player-2",
username: "Bravo",
accountUsername: null,
clanTag: undefined,
winRate: 0.4,
}),
+52
View File
@@ -153,6 +153,26 @@ describe("ClanMemberSchema", () => {
}
});
it("accepts an account username, null, or absence (older API)", () => {
const base = {
role: "member",
joinedAt: "2024-03-01T09:30:00.000Z",
publicId: "abc123",
};
const named = ClanMemberSchema.safeParse({
...base,
username: "bob.4821",
});
expect(named.success).toBe(true);
if (named.success) {
expect(named.data.username).toBe("bob.4821");
}
expect(
ClanMemberSchema.safeParse({ ...base, username: null }).success,
).toBe(true);
expect(ClanMemberSchema.safeParse(base).success).toBe(true);
});
it("rejects stats missing a bucket", () => {
const result = ClanMemberSchema.safeParse({
role: "member",
@@ -183,6 +203,21 @@ describe("ClanJoinRequestSchema", () => {
});
expect(result.success).toBe(false);
});
it("accepts the requester's account username, null, or absence", () => {
const base = {
publicId: "player-xyz",
createdAt: "2024-06-10T08:00:00.000Z",
};
expect(
ClanJoinRequestSchema.safeParse({ ...base, username: "bob.4821" })
.success,
).toBe(true);
expect(
ClanJoinRequestSchema.safeParse({ ...base, username: null }).success,
).toBe(true);
expect(ClanJoinRequestSchema.safeParse(base).success).toBe(true);
});
});
describe("ClanBanSchema", () => {
@@ -227,6 +262,23 @@ describe("ClanBanSchema", () => {
const result = ClanBanSchema.safeParse({ ...validBan, bannedBy: null });
expect(result.success).toBe(false);
});
it("accepts account usernames for both the banned player and the officer", () => {
const result = ClanBanSchema.safeParse({
...validBan,
username: null,
bannedByUsername: "bigboss",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.username).toBeNull();
expect(result.data.bannedByUsername).toBe("bigboss");
}
});
it("accepts a ban without the username fields (older API)", () => {
expect(ClanBanSchema.safeParse(validBan).success).toBe(true);
});
});
describe("ClanGameResultSchema", () => {