mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 23:08:01 +00:00
## 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>
113 lines
3.6 KiB
TypeScript
113 lines
3.6 KiB
TypeScript
// Mocks the output of translation functions to return predictable values.
|
|
vi.mock("../src/client/Utils", () => ({
|
|
translateText: (key: string, vars?: any) =>
|
|
vars ? `${key}:${JSON.stringify(vars)}` : key,
|
|
}));
|
|
|
|
import {
|
|
MAX_ACCOUNT_USERNAME_LENGTH,
|
|
MAX_CLAN_TAG_LENGTH,
|
|
MAX_USERNAME_LENGTH,
|
|
validateAccountUsername,
|
|
validateClanTag,
|
|
validateUsername,
|
|
} from "../src/core/validations/username";
|
|
|
|
describe("username.ts functions", () => {
|
|
describe("validateUsername", () => {
|
|
test("rejects non-string", () => {
|
|
// @ts-expect-error: Testing non-string input to validateUsername on purpose
|
|
const res = validateUsername(123);
|
|
expect(res.isValid).toBe(false);
|
|
expect(res.error).toBeDefined();
|
|
});
|
|
test("rejects too short", () => {
|
|
const res = validateUsername("ab");
|
|
expect(res.isValid).toBe(false);
|
|
});
|
|
test("rejects too long", () => {
|
|
const long = "a".repeat(MAX_USERNAME_LENGTH + 1);
|
|
const res = validateUsername(long);
|
|
expect(res.isValid).toBe(false);
|
|
});
|
|
test("rejects invalid chars", () => {
|
|
const res = validateUsername("Invalid!Name");
|
|
expect(res.isValid).toBe(false);
|
|
});
|
|
test("accepts valid ASCII names", () => {
|
|
const res = validateUsername("Good_Name123");
|
|
expect(res.isValid).toBe(true);
|
|
});
|
|
test("accepts allowed Unicode like ü", () => {
|
|
const res = validateUsername("Üser");
|
|
expect(res.isValid).toBe(true);
|
|
});
|
|
});
|
|
|
|
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("");
|
|
expect(res.isValid).toBe(true);
|
|
});
|
|
|
|
test("rejects too short clan tag", () => {
|
|
const res = validateClanTag("A");
|
|
expect(res.isValid).toBe(false);
|
|
expect(res.error).toBe("username.tag_too_short");
|
|
});
|
|
|
|
test("rejects invalid clan tag characters", () => {
|
|
const res = validateClanTag("A!");
|
|
expect(res.isValid).toBe(false);
|
|
expect(res.error).toBe("username.tag_invalid_chars");
|
|
});
|
|
|
|
test("rejects too long clan tag", () => {
|
|
const res = validateClanTag("A".repeat(MAX_CLAN_TAG_LENGTH + 1));
|
|
expect(res.isValid).toBe(false);
|
|
expect(res.error).toBe("username.tag_too_long");
|
|
});
|
|
|
|
test("accepts valid clan tag", () => {
|
|
const res = validateClanTag("AB12");
|
|
expect(res.isValid).toBe(true);
|
|
});
|
|
});
|
|
});
|