mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 17:20:23 +00:00
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:
@@ -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 "";
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user