mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 03:42:42 +00:00
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:
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
ClaimAllRewardsResponseSchema,
|
||||
ClaimRewardResponseSchema,
|
||||
FriendEntrySchema,
|
||||
GoogleUser,
|
||||
GoogleUserSchema,
|
||||
isTemporaryUsername,
|
||||
isVerifiedUsername,
|
||||
PlayerGameModeFilterSchema,
|
||||
PlayerGameResultSchema,
|
||||
PlayerGameTypeFilterSchema,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
PublicPlayerGameSchema,
|
||||
PublicPlayerGamesResponseSchema,
|
||||
PutUsernameResponseSchema,
|
||||
RankedLeaderboardEntrySchema,
|
||||
RewardSchema,
|
||||
UserMeResponseSchema,
|
||||
} from "../src/core/ApiSchemas";
|
||||
@@ -69,6 +72,91 @@ describe("PlayerProfileSchema", () => {
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a pre-rendered account username", () => {
|
||||
const result = PlayerProfileSchema.safeParse({
|
||||
...base,
|
||||
username: "bob.4821",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.username).toBe("bob.4821");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts username: null (player never set one)", () => {
|
||||
const result = PlayerProfileSchema.safeParse({ ...base, username: null });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a profile without username (older API)", () => {
|
||||
const result = PlayerProfileSchema.safeParse(base);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.username).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FriendEntrySchema", () => {
|
||||
const base = {
|
||||
publicId: "abc123",
|
||||
createdAt: "2024-01-15T12:00:00.000Z",
|
||||
};
|
||||
|
||||
it("accepts an entry with an account username", () => {
|
||||
const result = FriendEntrySchema.safeParse({
|
||||
...base,
|
||||
username: "bob.4821",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts username: null (friend never set one)", () => {
|
||||
const result = FriendEntrySchema.safeParse({ ...base, username: null });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an entry without username (older API)", () => {
|
||||
expect(FriendEntrySchema.safeParse(base).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RankedLeaderboardEntrySchema accountUsername", () => {
|
||||
const base = {
|
||||
rank: 1,
|
||||
elo: 1500,
|
||||
peakElo: 1600,
|
||||
wins: 10,
|
||||
losses: 5,
|
||||
total: 15,
|
||||
public_id: "abc123",
|
||||
username: "xX_Sniper_Xx",
|
||||
};
|
||||
|
||||
it("keeps accountUsername verbatim alongside the session username", () => {
|
||||
const result = RankedLeaderboardEntrySchema.safeParse({
|
||||
...base,
|
||||
accountUsername: "bob.4821",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.accountUsername).toBe("bob.4821");
|
||||
expect(result.data.username).toBe("xX_Sniper_Xx");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts accountUsername: null (player never set one)", () => {
|
||||
const result = RankedLeaderboardEntrySchema.safeParse({
|
||||
...base,
|
||||
accountUsername: null,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an entry without accountUsername (older API)", () => {
|
||||
expect(RankedLeaderboardEntrySchema.safeParse(base).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlayerGameModeFilterSchema", () => {
|
||||
@@ -567,3 +655,28 @@ describe("isTemporaryUsername", () => {
|
||||
expect(isTemporaryUsername(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isVerifiedUsername", () => {
|
||||
it.each(["bob", "big_boss", "a-b_c9"])(
|
||||
"treats bare (dotless) display %s as verified",
|
||||
(name) => {
|
||||
expect(isVerifiedUsername(name)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["bob.4821", "big_boss.0042"])(
|
||||
"treats suffixed display %s as not verified",
|
||||
(name) => {
|
||||
expect(isVerifiedUsername(name)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("never verifies an unset username", () => {
|
||||
expect(isVerifiedUsername(null)).toBe(false);
|
||||
expect(isVerifiedUsername(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("never verifies a TEMPORARY#### server rename, even though it is bare", () => {
|
||||
expect(isVerifiedUsername("TEMPORARY1234")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user