From 0a16d3c96c45c184407c9a0b19b9f1109e596de1 Mon Sep 17 00:00:00 2001 From: khannurien Date: Wed, 7 Oct 2026 06:50:43 +0000 Subject: [PATCH] =?UTF-8?q?v4:=20users=20can't=20rename=20themselves,=20/s?= =?UTF-8?q?ettings=20lays=20out=20again,=20and=20its=20selectors=20fit=20a?= =?UTF-8?q?=20phone=20=E2=80=94=20PATCH=20/api/me=20refuses=20a=20username?= =?UTF-8?q?=20with=20a=20400=20and=20changes=20nothing=20else=20in=20that?= =?UTF-8?q?=20request;=20a=20name=20changes=20only=20through=20PATCH=20/ap?= =?UTF-8?q?i/users/:key/username,=20which=20needs=20user:manage,=20from=20?= =?UTF-8?q?/@name/manage.=20The=20old=20name=20still=20stays=20an=20alias?= =?UTF-8?q?=20for=20good.=20renameUser=20lost=20its=2030-day=20limit=20and?= =?UTF-8?q?=20its=20exempt=20flag,=20and=20RENAME=5FLIMITS=20holds=20the?= =?UTF-8?q?=20slug=20limit=20alone.=20/settings=20shows=20the=20username?= =?UTF-8?q?=20as=20plain=20text,=20and=20the=20rename=20hint=20left=20the?= =?UTF-8?q?=20catalogs.=20The=20settings=20grid=20sized=20its=20control=20?= =?UTF-8?q?column=20to=20its=20widest=20content,=20the=20rename=20hint,=20?= =?UTF-8?q?which=20stretched=20every=20control=20under=20it;=20that=20colu?= =?UTF-8?q?mn=20now=20takes=20the=20remaining=20width=20and=20each=20contr?= =?UTF-8?q?ol=20keeps=20its=20own=20size.=20A=20segmented=20control=20wrap?= =?UTF-8?q?s=20instead=20of=20overflowing;=20in=20a=20settings=20row=20on?= =?UTF-8?q?=20a=20phone=20it=20takes=20the=20full=20width,=20and=20one=20o?= =?UTF-8?q?f=20more=20than=204=20options=20(the=20styles)=20lays=20out=20i?= =?UTF-8?q?n=20two=20even=20rows.=20.btn-border=20turned=20red=20on=20hove?= =?UTF-8?q?r,=20so=20Settings,=20Manage,=20Retry=20and=20Back=20looked=20l?= =?UTF-8?q?ike=20Log=20out;=20it=20is=20now=20neutral=20(Smooth's=20accent?= =?UTF-8?q?=20is=20red=20too),=20and=20only=20Log=20out=20takes=20the=20ne?= =?UTF-8?q?w=20btn-border--danger,=20muted=20until=20hovered=20red.=20A=20?= =?UTF-8?q? diff --git a/src/components/profile/UsernameField.tsx b/src/components/profile/UsernameField.tsx index 497a988c..0bb97cf4 100644 --- a/src/components/profile/UsernameField.tsx +++ b/src/components/profile/UsernameField.tsx @@ -1,6 +1,6 @@ /** - * Renders the field that renames a user, on the settings page for one's own - * name and on a profile's "manage" tab for a moderator. + * Renders the field that renames a user, on a profile's "manage" tab, which + * those with `user:manage` see: users can't rename themselves. * * @module */ @@ -14,16 +14,16 @@ import { useApiForm } from "../../hooks/form/useApiForm.ts"; import { FormError } from "../form/FormError.tsx"; import { SubmitButton } from "../form/SubmitButton.tsx"; -/** Passed to {@link UsernameField} by `Settings` and `ProfileManageTab`. */ +/** Passed to {@link UsernameField} by `ProfileManageTab`. */ export interface UsernameFieldProps { username: string; /** - * Sends the new name, to `PATCH /api/me` or - * `PATCH /api/users/:key/username`, and resolves to the name the server - * kept. A refusal rejects with its message, which the field shows. + * Sends the new name to `PATCH /api/users/:key/username`, and resolves to + * the name the server kept. A refusal rejects with its message, which the + * field shows. */ save: (username: string) => Promise; - /** Shown under the field, such as how often the name may change. */ + /** Shown under the field, such as what becomes of the old name. */ hint?: ReactNode; } diff --git a/src/components/shared/Segmented.tsx b/src/components/shared/Segmented.tsx index 28b0cf75..b17d57a8 100644 --- a/src/components/shared/Segmented.tsx +++ b/src/components/shared/Segmented.tsx @@ -29,12 +29,28 @@ export interface SegmentedProps { className?: string; } +/** + * Past this many options, a phone lays them out in two even rows rather than + * one row that would wrap unevenly (`.visibility-toggle--many`). + */ +const MAX_ONE_ROW = 4; + /** Renders the options as buttons, the one equal to `value` marked active. */ export function Segmented( { options, value, onChange, disabled, className }: SegmentedProps, ): React.JSX.Element { + const many = options.length > MAX_ONE_ROW; return ( -
+
{options.map((opt) => ( {me && ( - )} diff --git a/src/test/pages/Settings.test.tsx b/src/test/pages/Settings.test.tsx index e26580c5..75a3722d 100644 --- a/src/test/pages/Settings.test.tsx +++ b/src/test/pages/Settings.test.tsx @@ -4,7 +4,7 @@ import { userEvent } from "@testing-library/user-event"; import { Settings } from "../../pages/Settings.tsx"; import { DEFAULT_SETTINGS } from "../../../shared/settings.ts"; import { calls, fail, route } from "../fetch.ts"; -import { makeSession, makeToken, wire } from "../fixtures.ts"; +import { makeSession, makeToken } from "../fixtures.ts"; import { renderApp } from "../render.tsx"; import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts"; @@ -95,45 +95,12 @@ describe("Settings", () => { expect(weeklyOff).toHaveClass("active"); }); - test("a new username goes to the account and the session", async () => { - route( - "PATCH /api/me", - (req) => wire({ ...annSession.user, ...(req.body as object) }), - ); - const user = open(); - const name = await screen.findByRole("textbox", { name: "Username" }); - expect(screen.getByText(/once every 30 days/)).toBeInTheDocument(); - const rename = screen.getByRole("button", { name: "Rename" }); - expect(rename).toBeDisabled(); - await user.clear(name); - await user.type(name, "annie"); - await user.click(rename); - await waitFor(() => - expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.username) - .toBe("annie") - ); - expect(calls("PATCH /api/me")[0].body).toEqual({ username: "annie" }); - expect(screen.getByRole("textbox", { name: "Username" })).toHaveValue( - "annie", - ); - }); - - test("a refused username says why, and a malformed one isn't sent", async () => { - route( - "PATCH /api/me", - () => fail("A username can only be changed once every 30 days", 429), - ); - const user = open(); - const name = await screen.findByRole("textbox", { name: "Username" }); - await user.type(name, " x{Enter}"); - expect(await screen.findByText(/Letters, numbers and underscores/)) - .toBeInTheDocument(); - expect(calls("PATCH /api/me")).toHaveLength(0); - await user.clear(name); - await user.type(name, "annie{Enter}"); - expect(await screen.findByRole("alert")).toHaveTextContent( - "once every 30 days", - ); + test("the username is shown, and can't be changed here", async () => { + open(); + await screen.findByText("Username"); + expect(row("Username")).toHaveTextContent(annSession.user.username); + expect(screen.queryByRole("textbox", { name: "Username" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Rename" })).toBeNull(); }); test("change password keeps you signed in with the fresh tokens", async () => { diff --git a/src/test/pages/UserPublicProfile.test.tsx b/src/test/pages/UserPublicProfile.test.tsx index d3d82e35..e61be40d 100644 --- a/src/test/pages/UserPublicProfile.test.tsx +++ b/src/test/pages/UserPublicProfile.test.tsx @@ -505,7 +505,7 @@ describe("UserPublicProfile", () => { }); }); - test("a refused rename says why", async () => { + test("a refused rename says why, and a malformed one isn't sent", async () => { route( "PATCH /api/users/:id/username", () => fail("Username already exists", 409), @@ -515,7 +515,12 @@ describe("UserPublicProfile", () => { url: "/@ann/manage", }); const name = await screen.findByRole("textbox", { name: "Username" }); - await user.type(name, "x{Enter}"); + await user.type(name, " x{Enter}"); + expect(await screen.findByText(/Letters, numbers and underscores/)) + .toBeInTheDocument(); + expect(calls("PATCH /api/users/:id/username")).toHaveLength(0); + await user.clear(name); + await user.type(name, "annx{Enter}"); expect(await screen.findByRole("alert")).toHaveTextContent( "Username already exists", );