feat(client): account-level custom usernames (base.suffix + premium bare names) (#4644)

## 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 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-19 15:05:31 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent d6b4b0e00c
commit 9c14ab04e0
9 changed files with 716 additions and 9 deletions
+16 -2
View File
@@ -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 {
</div>
</div>
</div>
${this.renderRewardsPanel()} ${this.renderSubscriptionPanel()}
${this.renderUsernamePanel()} ${this.renderRewardsPanel()}
${this.renderSubscriptionPanel()}
</div>
`;
}
@@ -359,7 +361,8 @@ export class AccountModal extends BaseModal {
</div>
</div>
</div>
${this.renderRewardsPanel()} ${this.renderSubscriptionPanel()}
${this.renderUsernamePanel()} ${this.renderRewardsPanel()}
${this.renderSubscriptionPanel()}
</div>
`;
}
@@ -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`<username-panel
.player=${player}
@username-changed=${() => this.requestUpdate()}
></username-panel>`;
}
private renderRewardsPanel(): TemplateResult | "" {
const rewards = this.userMeResponse?.player?.rewards ?? [];
if (rewards.length === 0) return "";
+75
View File
@@ -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<UpdateUsernameResult> {
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,
+34 -7
View File
@@ -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);
}
}
+270
View File
@@ -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<string, unknown>) {
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<UpdateUsernameResult, { ok: true }>,
): 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`
<div
class="mt-3 px-3 py-2 rounded-lg border border-amber-500/50 bg-amber-900/30 text-amber-200 text-sm"
>
${translateText("account_modal.username_temporary_notice")}
</div>
`;
}
const claimExpiresAt = this.player.usernameClaimExpiresAt;
if (this.player.usernameStatus === "claimed" && claimExpiresAt) {
return html`
<div
class="mt-3 px-3 py-2 rounded-lg border border-amber-500/50 bg-amber-900/30 text-amber-200 text-sm"
>
${translateText("account_modal.username_grace_warning", {
name: this.player.usernameBase ?? "",
date: this.formatDate(new Date(claimExpiresAt)),
})}
</div>
`;
}
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`
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<h3 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
<span class="text-blue-400">🏷️</span>
${translateText("account_modal.username_title")}
</h3>
${this.player.username
? html`<div class="text-white text-lg font-medium">
${this.player.username}
</div>`
: html`<div class="text-white/50 text-sm">
${translateText("account_modal.username_not_set")}
</div>`}
${this.renderNotices()}
<div class="mt-4 flex items-stretch gap-2">
<input
type="text"
.value=${this.draft}
@input=${this.handleInput}
@keydown=${(e: KeyboardEvent) => {
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"
/>
<o-button
variant="primary"
size="md"
translationKey="account_modal.username_save"
.disable=${!canSave}
@click=${this.handleSave}
></o-button>
</div>
${locked
? html`<div class="mt-2 text-white/50 text-sm">
${translateText("account_modal.username_cooldown_until", {
date: this.formatDate(cooldownEnd),
})}
</div>`
: nothing}
${this.error
? html`<div class="mt-2 text-red-400 text-sm">${this.error}</div>`
: nothing}
</div>
`;
}
}