From 9c14ab04e02817686e3e9aaf05d0466dff742077 Mon Sep 17 00:00:00 2001 From: Evan Date: Sun, 19 Jul 2026 15:05:31 -0700 Subject: [PATCH] feat(client): account-level custom usernames (base.suffix + premium bare names) (#4644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Client integration for account-level usernames (backend: openfrontio/infra#434). Players get one canonical account name stored as a base plus a server-assigned 4-digit suffix (`bob.4821`); subscribers display the bare base (`bob`) with an exclusive case-insensitive claim on it. - **Schemas** (`src/core/ApiSchemas.ts`): new `player` username fields on `GET /users/@me`, `UsernameStatusSchema`, `PutUsernameResponseSchema`, and an `isTemporaryUsername()` helper. The display name is rendered exactly as the server resolves it β€” the client never assembles `base.suffix`. Discriminators stay strings (leading zeros). - **API** (`src/client/Api.ts`): `updateUsername()` for `PUT /users/@me/username`, returning a discriminated result covering every documented failure: `invalid` (400), `profane` (400 + `USERNAME_PROFANE`), `taken` (both 409 bodies), `cooldown` (429 + `Retry-After`), `failed`. - **UI** (`src/client/components/UsernamePanel.ts`, in the Account tab): set/change form prefilled with the base, live 3–20 / `[a-zA-Z0-9_-]` validation (`validateAccountUsername`), form locked with the date while the 30-day cooldown runs, grace-period warning for lapsed claim holders, and a free-rename notice after a server-side `TEMPORARY####` rename. The confirm dialog composes warnings by state: 30-day lock always, case-only-change notice, and abandon-reservation warning for `claimed` players. Suffixes are never mentioned in user-facing copy β€” subscribers should feel like they own the bare name outright. - **Load prompt** (`src/client/Main.ts`): on a clean homepage load, a subscriber renamed to `TEMPORARY####` is prompted to pick a new name (free); takes priority over the rewards popup (the account modal shows rewards anyway). ## Deploy notes **No deploy-order constraint.** All new `/users/@me` fields are optional in the schema: against the current API the response still parses and the panel simply doesn't render (`usernameStatus === undefined`). The client can ship before or after infra#434. ## Test plan - New schema tests (old-API absence, leading-zero discriminators, unknown statuses, PUT payload) and validation tests (dots, spaces, unicode, trim) β€” full suite passes (2,031 + 171). - Drove the real app headless with synthetic `/users/@me` payloads: verified all panel states (unclaimed, never-set, claimed+grace, TEMPORARY, cooldown-locked, old API hidden), the confirm-dialog warnings, and the inline error path on a failed PUT. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 --- resources/lang/en.json | 20 ++ src/client/AccountModal.ts | 18 +- src/client/Api.ts | 75 +++++++ src/client/Main.ts | 41 +++- src/client/components/UsernamePanel.ts | 270 +++++++++++++++++++++++++ src/core/ApiSchemas.ts | 49 +++++ src/core/validations/username.ts | 50 +++++ tests/ApiSchemas.test.ts | 164 +++++++++++++++ tests/Censor.test.ts | 38 ++++ 9 files changed, 716 insertions(+), 9 deletions(-) create mode 100644 src/client/components/UsernamePanel.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 0969b2559..fc826bd00 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -67,6 +67,25 @@ "tab_stats": "Stats", "title": "Account", "unclaimed_rewards": "Unclaimed Rewards", + "username_changed": "Username changed to {name}.", + "username_confirm_abandon": "You will permanently give up your reserved name \"{name}\".", + "username_confirm_body": "Your new name will be locked for 30 days.", + "username_confirm_button": "Change name", + "username_confirm_case_only": "The new name only differs from your current one in capitalization β€” it will still restart the 30-day lock.", + "username_confirm_heading": "Change username?", + "username_cooldown_until": "You can change your username again on {date}.", + "username_error_cooldown": "You can change your username again in {days} day(s).", + "username_error_failed": "Couldn't change your username. Please try again later.", + "username_error_profane": "That username isn't allowed.", + "username_error_taken": "That username is already taken β€” try another.", + "username_grace_warning": "You'll lose the exclusive right to \"{name}\" on {date}. Resubscribe to keep it.", + "username_not_set": "Claim your username β€” it identifies you across all your games.", + "username_placeholder": "Enter a username", + "username_save": "Save", + "username_temporary_notice": "Your name was taken by another player. Pick a new one β€” this rename is free.", + "username_temporary_prompt": "Your username was taken by another player. Choose a new one now? The rename is free.", + "username_temporary_prompt_confirm": "Choose a name", + "username_title": "Username", "your_subscription": "Your Subscription" }, "build_menu": { @@ -1594,6 +1613,7 @@ "zoom_out_desc": "Zoom out the map" }, "username": { + "account_invalid_chars": "Username can only contain letters, numbers, underscores, and hyphens.", "enter_username": "Enter your username", "invalid_chars": "Username can only contain letters, numbers, spaces, and underscores.", "not_string": "Username must be a string.", diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index c9ae02e56..c62b34505 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -32,6 +32,7 @@ import "./components/RewardsPanel"; import type { RewardsChangedDetail } from "./components/RewardsPanel"; import "./components/SubscriptionPanel"; import { modalHeader } from "./components/ui/ModalHeader"; +import "./components/UsernamePanel"; import { fetchCosmetics } from "./Cosmetics"; import { crazyGamesSDK, type CrazyGamesUser } from "./CrazyGamesSDK"; import { playerProfileUrl } from "./PlayerProfileModal"; @@ -329,7 +330,8 @@ export class AccountModal extends BaseModal { - ${this.renderRewardsPanel()} ${this.renderSubscriptionPanel()} + ${this.renderUsernamePanel()} ${this.renderRewardsPanel()} + ${this.renderSubscriptionPanel()} `; } @@ -359,7 +361,8 @@ export class AccountModal extends BaseModal { - ${this.renderRewardsPanel()} ${this.renderSubscriptionPanel()} + ${this.renderUsernamePanel()} ${this.renderRewardsPanel()} + ${this.renderSubscriptionPanel()} `; } @@ -457,6 +460,17 @@ export class AccountModal extends BaseModal { `; } + // Account-username management (custom-usernames). Hidden when the API + // doesn't return the username fields yet (older backend). + private renderUsernamePanel(): TemplateResult | "" { + const player = this.userMeResponse?.player; + if (!player || player.usernameStatus === undefined) return ""; + return html` this.requestUpdate()} + >`; + } + private renderRewardsPanel(): TemplateResult | "" { const rewards = this.userMeResponse?.player?.rewards ?? []; if (rewards.length === 0) return ""; diff --git a/src/client/Api.ts b/src/client/Api.ts index f899ef72a..8c3d47f8b 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -13,6 +13,8 @@ import { PlayerProfileSchema, PublicPlayerGamesResponse, PublicPlayerGamesResponseSchema, + PutUsernameResponse, + PutUsernameResponseSchema, RankedLeaderboardResponse, RankedLeaderboardResponseSchema, UserMeResponse, @@ -229,6 +231,79 @@ export async function setMarketingConsent( } } +export type UpdateUsernameResult = + | { ok: true; data: PutUsernameResponse } + | { ok: false; code: "invalid"; message?: string } + | { ok: false; code: "profane" } + | { ok: false; code: "taken" } + | { ok: false; code: "cooldown"; retryAfterSeconds: number | null } + | { ok: false; code: "failed" }; + +// PUT /users/@me/username β€” renames the account username. Every failure is +// atomic (no name change, no cooldown consumed). Both 409 bodies ("name +// exclusively held" and "suffix space exhausted") map to "taken": the user +// remedy is the same β€” pick another name. Invalidates the cached /users/@me +// on success so the next read reflects the new name. +export async function updateUsername( + username: string, +): Promise { + try { + const response = await fetch(`${getApiBase()}/users/@me/username`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: await getAuthHeader(), + }, + body: JSON.stringify({ username }), + }); + if (response.status === 401) { + await logOut(); + return { ok: false, code: "failed" }; + } + if (response.status === 400) { + const body = await response.json().catch(() => null); + if (body?.code === "USERNAME_PROFANE") { + return { ok: false, code: "profane" }; + } + return { + ok: false, + code: "invalid", + message: typeof body?.reason === "string" ? body.reason : undefined, + }; + } + if (response.status === 409) { + return { ok: false, code: "taken" }; + } + if (response.status === 429) { + const retryAfter = response.headers.get("Retry-After"); + const seconds = retryAfter === null ? NaN : Number(retryAfter); + return { + ok: false, + code: "cooldown", + retryAfterSeconds: Number.isFinite(seconds) ? seconds : null, + }; + } + if (!response.ok) { + console.error( + "updateUsername: request failed", + response.status, + response.statusText, + ); + return { ok: false, code: "failed" }; + } + const parsed = PutUsernameResponseSchema.safeParse(await response.json()); + if (!parsed.success) { + console.error("updateUsername: Zod validation failed", parsed.error); + return { ok: false, code: "failed" }; + } + invalidateUserMe(); + return { ok: true, data: parsed.data }; + } catch (e) { + console.error("updateUsername: request failed", e); + return { ok: false, code: "failed" }; + } +} + export async function purchaseWithCurrency( cosmeticType: "pattern" | "skin" | "flag" | "crown" | "effect", cosmeticName: string, diff --git a/src/client/Main.ts b/src/client/Main.ts index 3c3c542aa..79b0a52e4 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -1,6 +1,6 @@ import version from "resources/version.txt?raw"; import { ClientEnv } from "src/client/ClientEnv"; -import { UserMeResponse } from "../core/ApiSchemas"; +import { isTemporaryUsername, UserMeResponse } from "../core/ApiSchemas"; import { assetUrl } from "../core/AssetUrls"; import { EventBus } from "../core/EventBus"; import { @@ -536,14 +536,41 @@ class Client { "Sharing this ID will allow others to view your game history and stats.", ); - // Unclaimed-rewards popup β€” only on a clean homepage load, never over - // a deep link (join URL, #modal=..., #purchase-completed, ...). - const rewards = userMeResponse.player.rewards ?? []; + // Popups below only on a clean homepage load, never over a deep link + // (join URL, #modal=..., #purchase-completed, ...). + const cleanHomepage = + window.location.pathname === "/" && window.location.hash === ""; + + // The server renamed this subscriber to TEMPORARY#### because their + // bare name was exclusively taken while they were unentitled; the + // rename is free (cooldown cleared). Prompt for a real name; takes + // priority over the rewards popup β€” the account modal shows the + // rewards panel anyway. + const { usernameStatus, usernameBase } = userMeResponse.player; if ( - rewards.length > 0 && - window.location.pathname === "/" && - window.location.hash === "" + cleanHomepage && + (usernameStatus === "premium" || usernameStatus === "indefinite") && + isTemporaryUsername(usernameBase) ) { + const goRename = await showInGameConfirm( + translateText("account_modal.username_temporary_prompt"), + { + heading: translateText("account_modal.username_title"), + variant: "warning", + confirmText: translateText( + "account_modal.username_temporary_prompt_confirm", + ), + }, + ); + if (goRename) { + window.location.hash = "modal=account"; + } + return; + } + + // Unclaimed-rewards popup. + const rewards = userMeResponse.player.rewards ?? []; + if (rewards.length > 0 && cleanHomepage) { this.rewardsModal?.openWithRewards(rewards); } } diff --git a/src/client/components/UsernamePanel.ts b/src/client/components/UsernamePanel.ts new file mode 100644 index 000000000..49b66912e --- /dev/null +++ b/src/client/components/UsernamePanel.ts @@ -0,0 +1,270 @@ +import { html, LitElement, nothing, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { isTemporaryUsername, UserMeResponse } from "../../core/ApiSchemas"; +import { + MAX_ACCOUNT_USERNAME_LENGTH, + MIN_ACCOUNT_USERNAME_LENGTH, + validateAccountUsername, +} from "../../core/validations/username"; +import { updateUsername, UpdateUsernameResult } from "../Api"; +import { showInGameConfirm } from "../InGameModal"; +import { translateText } from "../Utils"; +import "./baseComponents/Button"; + +type UserMePlayer = UserMeResponse["player"]; + +/** + * Account-username management card for the Account tab. Renders the + * server-resolved display name as-is (never assembles base + suffix), the + * set/change form with client-side validation and the 30-day cooldown, the + * grace-period warning for lapsed claim holders, and the free-rename notice + * after a TEMPORARY#### server rename. + */ +@customElement("username-panel") +export class UsernamePanel extends LitElement { + @property({ attribute: false }) player!: UserMePlayer; + + @state() private draft = ""; + @state() private busy = false; + @state() private error = ""; + + createRenderRoot() { + return this; + } + + willUpdate(changed: Map) { + if (changed.has("player")) { + // Prefill with the base only β€” never put ".suffix" in the input. + this.draft = this.player?.usernameBase ?? ""; + this.error = ""; + } + } + + // The date the player may next self-rename, or null when a rename is + // allowed now (nextUsernameChangeAt may be null OR in the past). + private cooldownEnd(): Date | null { + const at = this.player.nextUsernameChangeAt; + if (!at) return null; + const date = new Date(at); + return date.getTime() > Date.now() ? date : null; + } + + private isTemporary(): boolean { + const status = this.player.usernameStatus; + return ( + (status === "premium" || status === "indefinite") && + isTemporaryUsername(this.player.usernameBase) + ); + } + + private formatDate(date: Date): string { + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + } + + private handleInput(e: Event) { + this.draft = (e.target as HTMLInputElement).value; + const trimmed = this.draft.trim(); + if (trimmed.length === 0) { + this.error = ""; + return; + } + const result = validateAccountUsername(trimmed); + this.error = result.isValid ? "" : (result.error ?? ""); + } + + private async handleSave() { + if (this.busy) return; + const name = this.draft.trim(); + const validation = validateAccountUsername(name); + if (!validation.isValid) { + this.error = validation.error ?? ""; + return; + } + + const base = this.player.usernameBase; + const warnings = [translateText("account_modal.username_confirm_body")]; + // A case-only (or identical) resubmission still counts as a full rename + // and restarts the cooldown. + if (base !== null && base !== undefined) { + if (name.toLowerCase() === base.toLowerCase()) { + warnings.push( + translateText("account_modal.username_confirm_case_only"), + ); + } + // A lapsed claim holder who renames abandons the old reservation + // permanently β€” it does not transfer to the new name. + if (this.player.usernameStatus === "claimed") { + warnings.push( + translateText("account_modal.username_confirm_abandon", { + name: base, + }), + ); + } + } + + const confirmed = await showInGameConfirm(warnings.join(" "), { + heading: translateText("account_modal.username_confirm_heading"), + variant: "warning", + confirmText: translateText("account_modal.username_confirm_button"), + }); + if (!confirmed) return; + + this.busy = true; + const result = await updateUsername(name); + this.busy = false; + + if (result.ok) { + // The panel and AccountModal share the same player object, so updating + // it here keeps every consumer consistent; the event just triggers the + // parent re-render. + this.player.username = result.data.username; + this.player.usernameBase = result.data.base; + this.player.usernameDiscriminator = result.data.discriminator; + this.player.usernameStatus = result.data.usernameStatus; + this.player.nextUsernameChangeAt = result.data.nextUsernameChangeAt; + // A rename either kept the claim (premium/indefinite) or abandoned it + // (unclaimed) β€” either way no grace deadline remains. + this.player.usernameClaimExpiresAt = null; + this.draft = result.data.base; + this.error = ""; + this.requestUpdate(); + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message: translateText("account_modal.username_changed", { + name: result.data.username, + }), + color: "green", + duration: 4000, + }, + }), + ); + this.dispatchEvent( + new CustomEvent("username-changed", { + detail: result.data, + bubbles: true, + composed: true, + }), + ); + } else { + this.error = this.errorMessage(result); + } + } + + private errorMessage( + result: Exclude, + ): string { + switch (result.code) { + case "profane": + return translateText("account_modal.username_error_profane"); + case "taken": + return translateText("account_modal.username_error_taken"); + case "cooldown": { + // Only reachable via a race (e.g. a rename on another device) β€” the + // form is disabled while the client-known cooldown runs. + if (result.retryAfterSeconds !== null) { + return translateText("account_modal.username_error_cooldown", { + days: Math.max(1, Math.ceil(result.retryAfterSeconds / 86_400)), + }); + } + return translateText("account_modal.username_error_failed"); + } + case "invalid": + return ( + result.message ?? translateText("account_modal.username_error_failed") + ); + default: + return translateText("account_modal.username_error_failed"); + } + } + + private renderNotices(): TemplateResult | typeof nothing { + if (this.isTemporary()) { + return html` +
+ ${translateText("account_modal.username_temporary_notice")} +
+ `; + } + const claimExpiresAt = this.player.usernameClaimExpiresAt; + if (this.player.usernameStatus === "claimed" && claimExpiresAt) { + return html` +
+ ${translateText("account_modal.username_grace_warning", { + name: this.player.usernameBase ?? "", + date: this.formatDate(new Date(claimExpiresAt)), + })} +
+ `; + } + return nothing; + } + + render() { + if (!this.player || this.player.usernameStatus === undefined) + return nothing; + const cooldownEnd = this.cooldownEnd(); + const locked = cooldownEnd !== null; + const trimmed = this.draft.trim(); + const canSave = + !locked && + !this.busy && + trimmed.length >= MIN_ACCOUNT_USERNAME_LENGTH && + this.error === ""; + return html` +
+

+ 🏷️ + ${translateText("account_modal.username_title")} +

+ ${this.player.username + ? html`
+ ${this.player.username} +
` + : html`
+ ${translateText("account_modal.username_not_set")} +
`} + ${this.renderNotices()} +
+ { + if (e.key === "Enter" && canSave) void this.handleSave(); + }} + placeholder=${translateText("account_modal.username_placeholder")} + maxlength=${MAX_ACCOUNT_USERNAME_LENGTH} + ?disabled=${locked || this.busy} + class="flex-1 min-w-0 px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10 disabled:opacity-50 disabled:hover:bg-white/5" + /> + +
+ ${locked + ? html`
+ ${translateText("account_modal.username_cooldown_until", { + date: this.formatDate(cooldownEnd), + })} +
` + : nothing} + ${this.error + ? html`
${this.error}
` + : nothing} +
+ `; + } +} diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 219bb910a..bd0c587bd 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -108,6 +108,26 @@ export type ClaimAllRewardsResponse = z.infer< typeof ClaimAllRewardsResponseSchema >; +// Account-username lifecycle. `unclaimed`: no bare-name reservation (default). +// `claimed`: reservation held but subscription lapsed β€” the suffix shows again +// and a grace deadline runs. `premium`: subscribed, bare display. `indefinite`: +// admin-locked bare display. Statuses change server-side without client action +// (Stripe webhooks, admin edits) β€” re-fetch /users/@me rather than caching. +export const UsernameStatusSchema = z.enum([ + "unclaimed", + "claimed", + "premium", + "indefinite", +]); +export type UsernameStatus = z.infer; + +// When a player subscribes while someone else exclusively holds their bare +// name, the server renames them to TEMPORARY#### and clears their cooldown so +// the rename is free. Detect it to prompt for a real name. +export function isTemporaryUsername(base: string | null | undefined): boolean { + return typeof base === "string" && /^TEMPORARY\d{4}$/.test(base); +} + export const UserMeResponseSchema = z.object({ user: z.object({ discord: DiscordUserSchema.optional(), @@ -123,6 +143,23 @@ export const UserMeResponseSchema = z.object({ // True when the player may list a custom lobby publicly. The API decides // which subscriptions/grants confer this. canCreatePublicLobbies: z.boolean(), + // Account username (custom-usernames). All optional so responses from an + // API without the feature still parse; absent means the same as never set. + // `username` is the server-resolved DISPLAY form β€” the bare base for an + // entitled claim holder, otherwise "base.suffix". Render it as-is; never + // assemble base + discriminator client-side. The discriminator is exactly + // 4 digits and may have leading zeros β€” keep it a string. + username: z.string().nullable().optional(), + usernameBase: z.string().nullable().optional(), + usernameDiscriminator: z.string().nullable().optional(), + usernameStatus: UsernameStatusSchema.optional(), + // Only non-null in `claimed`: when the exclusive right to the bare name + // becomes takeable by another subscriber. A past date means "at risk", + // not "lost" β€” it stays set until the name is actually taken. + usernameClaimExpiresAt: z.iso.datetime().nullable().optional(), + // When the player may next self-rename. May be in the past β€” past or + // null both mean a rename is allowed. + nextUsernameChangeAt: z.iso.datetime().nullable().optional(), flares: z.string().array().optional(), achievements: z.object({ singleplayerMap: z.array(SingleplayerMapAchievementSchema), @@ -190,6 +227,18 @@ export type UserSubscription = NonNullable< NonNullable >; +// PUT /users/@me/username success payload. `username` is the resolved display +// form (safe for optimistic UI). The suffix is re-rolled on every rename and +// the response carries the fresh 30-day cooldown. +export const PutUsernameResponseSchema = z.object({ + username: z.string(), + base: z.string(), + discriminator: z.string(), + usernameStatus: UsernameStatusSchema, + nextUsernameChangeAt: z.iso.datetime().nullable(), +}); +export type PutUsernameResponse = z.infer; + export const PlayerStatsLeafSchema = z.object({ wins: BigIntStringSchema, losses: BigIntStringSchema, diff --git a/src/core/validations/username.ts b/src/core/validations/username.ts index c117e7d8c..85847d6dd 100644 --- a/src/core/validations/username.ts +++ b/src/core/validations/username.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import { translateText } from "../../client/Utils"; import { ClanTagSchema, UsernameSchema } from "../Schemas"; @@ -6,6 +7,19 @@ export const MAX_USERNAME_LENGTH = 27; export const MIN_CLAN_TAG_LENGTH = 2; export const MAX_CLAN_TAG_LENGTH = 5; +export const MIN_ACCOUNT_USERNAME_LENGTH = 3; +export const MAX_ACCOUNT_USERNAME_LENGTH = 20; + +// Mirrors the API's account-username rules (infra src/api/lib/Usernames.ts) +// for instant form feedback; profanity and uniqueness stay server-side. No +// dots (the dot separates base from suffix) and no spaces/unicode. +export const AccountUsernameSchema = z + .string() + .trim() + .min(MIN_ACCOUNT_USERNAME_LENGTH) + .max(MAX_ACCOUNT_USERNAME_LENGTH) + .regex(/^[a-zA-Z0-9_-]+$/); + export function validateUsername(username: string): { isValid: boolean; error?: string; @@ -47,6 +61,42 @@ export function validateUsername(username: string): { return { isValid: true }; } +export function validateAccountUsername(username: string): { + isValid: boolean; + error?: string; +} { + const parsed = AccountUsernameSchema.safeParse(username); + + if (!parsed.success) { + const errType = parsed.error.issues[0].code; + + if (errType === "too_small") { + return { + isValid: false, + error: translateText("username.too_short", { + min: MIN_ACCOUNT_USERNAME_LENGTH, + }), + }; + } + + if (errType === "too_big") { + return { + isValid: false, + error: translateText("username.too_long", { + max: MAX_ACCOUNT_USERNAME_LENGTH, + }), + }; + } + + return { + isValid: false, + error: translateText("username.account_invalid_chars"), + }; + } + + return { isValid: true }; +} + export function validateClanTag(clanTag: string): { isValid: boolean; error?: string; diff --git a/tests/ApiSchemas.test.ts b/tests/ApiSchemas.test.ts index aa2d115e2..00f54b836 100644 --- a/tests/ApiSchemas.test.ts +++ b/tests/ApiSchemas.test.ts @@ -3,12 +3,14 @@ import { ClaimRewardResponseSchema, GoogleUser, GoogleUserSchema, + isTemporaryUsername, PlayerGameModeFilterSchema, PlayerGameResultSchema, PlayerGameTypeFilterSchema, PlayerProfileSchema, PublicPlayerGameSchema, PublicPlayerGamesResponseSchema, + PutUsernameResponseSchema, RewardSchema, UserMeResponseSchema, } from "../src/core/ApiSchemas"; @@ -403,3 +405,165 @@ describe("UserMeResponseSchema canCreatePublicLobbies", () => { ).toBe(false); }); }); + +describe("UserMeResponseSchema account username", () => { + const basePlayer = { + publicId: "p1", + adfree: false, + unlimitedRanked: false, + canCreatePublicLobbies: false, + achievements: { singleplayerMap: [] }, + friends: [], + subscription: null, + }; + + it("accepts a lapsed claim holder (suffix showing, grace deadline set)", () => { + const result = UserMeResponseSchema.safeParse({ + user: {}, + player: { + ...basePlayer, + username: "Bob.4821", + usernameBase: "Bob", + usernameDiscriminator: "4821", + usernameStatus: "claimed", + usernameClaimExpiresAt: "2026-08-17T19:42:00.000Z", + nextUsernameChangeAt: null, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.player.username).toBe("Bob.4821"); + expect(result.data.player.usernameStatus).toBe("claimed"); + } + }); + + it("accepts a response without any username fields (older API)", () => { + const result = UserMeResponseSchema.safeParse({ + user: {}, + player: basePlayer, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.player.usernameStatus).toBeUndefined(); + } + }); + + it("accepts a player who never set a name (all null, unclaimed)", () => { + const result = UserMeResponseSchema.safeParse({ + user: {}, + player: { + ...basePlayer, + username: null, + usernameBase: null, + usernameDiscriminator: null, + usernameStatus: "unclaimed", + usernameClaimExpiresAt: null, + nextUsernameChangeAt: null, + }, + }); + expect(result.success).toBe(true); + }); + + it("keeps a leading-zero discriminator as a string", () => { + const result = UserMeResponseSchema.safeParse({ + user: {}, + player: { + ...basePlayer, + username: "Ann.0042", + usernameBase: "Ann", + usernameDiscriminator: "0042", + usernameStatus: "unclaimed", + usernameClaimExpiresAt: null, + nextUsernameChangeAt: null, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.player.usernameDiscriminator).toBe("0042"); + } + }); + + it("rejects an unknown usernameStatus", () => { + expect( + UserMeResponseSchema.safeParse({ + user: {}, + player: { ...basePlayer, usernameStatus: "expired" }, + }).success, + ).toBe(false); + }); + + it("rejects a non-ISO usernameClaimExpiresAt", () => { + expect( + UserMeResponseSchema.safeParse({ + user: {}, + player: { + ...basePlayer, + usernameStatus: "claimed", + usernameClaimExpiresAt: "next month", + }, + }).success, + ).toBe(false); + }); +}); + +describe("PutUsernameResponseSchema", () => { + const base = { + username: "NewName.7302", + base: "NewName", + discriminator: "7302", + usernameStatus: "unclaimed", + nextUsernameChangeAt: "2026-08-17T19:42:00.000Z", + }; + + it("accepts a rename result", () => { + const result = PutUsernameResponseSchema.safeParse(base); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.username).toBe("NewName.7302"); + expect(result.data.discriminator).toBe("7302"); + } + }); + + it("accepts a null cooldown", () => { + expect( + PutUsernameResponseSchema.safeParse({ + ...base, + nextUsernameChangeAt: null, + }).success, + ).toBe(true); + }); + + it("rejects a numeric discriminator (leading zeros would be lost)", () => { + expect( + PutUsernameResponseSchema.safeParse({ ...base, discriminator: 7302 }) + .success, + ).toBe(false); + }); + + it("rejects a missing base", () => { + const rest: Record = { ...base }; + delete rest.base; + expect(PutUsernameResponseSchema.safeParse(rest).success).toBe(false); + }); +}); + +describe("isTemporaryUsername", () => { + it.each(["TEMPORARY0042", "TEMPORARY9999"])("detects %s", (name) => { + expect(isTemporaryUsername(name)).toBe(true); + }); + + it.each([ + "temporary1234", + "TEMPORARY123", + "TEMPORARY12345", + "TEMPORARYabcd", + "Bob", + ])("rejects %s", (name) => { + expect(isTemporaryUsername(name)).toBe(false); + }); + + it("handles null and undefined bases", () => { + expect(isTemporaryUsername(null)).toBe(false); + expect(isTemporaryUsername(undefined)).toBe(false); + }); +}); diff --git a/tests/Censor.test.ts b/tests/Censor.test.ts index 87d42c5d5..2c61035e9 100644 --- a/tests/Censor.test.ts +++ b/tests/Censor.test.ts @@ -5,8 +5,10 @@ vi.mock("../src/client/Utils", () => ({ })); import { + MAX_ACCOUNT_USERNAME_LENGTH, MAX_CLAN_TAG_LENGTH, MAX_USERNAME_LENGTH, + validateAccountUsername, validateClanTag, validateUsername, } from "../src/core/validations/username"; @@ -42,6 +44,42 @@ describe("username.ts functions", () => { }); }); + describe("validateAccountUsername", () => { + test("rejects too short", () => { + const res = validateAccountUsername("ab"); + expect(res.isValid).toBe(false); + expect(res.error).toContain("username.too_short"); + }); + + test("rejects too long", () => { + const res = validateAccountUsername( + "a".repeat(MAX_ACCOUNT_USERNAME_LENGTH + 1), + ); + expect(res.isValid).toBe(false); + expect(res.error).toContain("username.too_long"); + }); + + test("rejects dots (the dot separates base from suffix)", () => { + const res = validateAccountUsername("bob.4821"); + expect(res.isValid).toBe(false); + expect(res.error).toBe("username.account_invalid_chars"); + }); + + test("rejects spaces and unicode", () => { + expect(validateAccountUsername("bob smith").isValid).toBe(false); + expect(validateAccountUsername("Üser").isValid).toBe(false); + }); + + test("accepts letters, digits, underscore, and hyphen", () => { + expect(validateAccountUsername("Good_Name-123").isValid).toBe(true); + }); + + test("trims before validating length", () => { + expect(validateAccountUsername(" bob ").isValid).toBe(true); + expect(validateAccountUsername(" ab ").isValid).toBe(false); + }); + }); + describe("validateClanTag", () => { test("accepts empty clan tag", () => { const res = validateClanTag("");