mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 01:42:27 +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:
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof UsernameStatusSchema>;
|
||||
|
||||
// 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<UserMeResponse["player"]["subscription"]>
|
||||
>;
|
||||
|
||||
// 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<typeof PutUsernameResponseSchema>;
|
||||
|
||||
export const PlayerStatsLeafSchema = z.object({
|
||||
wins: BigIntStringSchema,
|
||||
losses: BigIntStringSchema,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown> = { ...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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("");
|
||||
|
||||
Reference in New Issue
Block a user