v4: users can't rename themselves, /settings lays out again, and its selectors fit a phone — PATCH /api/me refuses a username with a 400 and changes nothing else in that request; a name changes only through PATCH /api/users/:key/username, which needs user:manage, from /@name/manage. The old name still stays an alias for good. renameUser lost its 30-day limit and its exempt flag, and RENAME_LIMITS holds the slug limit alone. /settings shows the username as plain text, and the rename hint left the catalogs. The settings grid sized its control column to its widest content, the rename hint, which stretched every control under it; that column now takes the remaining width and each control keeps its own size. A segmented control wraps instead of overflowing; in a settings row on a phone it takes the full width, and one of more than 4 options (the styles) lays out in two even rows. .btn-border turned red on hover, so Settings, Manage, Retry and Back looked like Log out; it is now neutral (Smooth's accent is red too), and only Log out takes the new btn-border--danger, muted until hovered red. A <button> and a <Link> .btn-border are now the same height. API.md and the module docs say who renames; the backend tests rename through the moderator route and check a self-rename is refused, and the malformed-name check moved to the manage tab's test. 254 backend and 667 frontend tests pass, and tsc, deno check and doc:lint are clean; Smooth, Brutalist, GeoCities and Kanagawa were checked in screenshots at desktop and phone widths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-07 06:50:43 +00:00
parent 4468438ba7
commit 0a16d3c96c
21 changed files with 235 additions and 307 deletions

8
API.md
View File

@@ -56,8 +56,8 @@ page to a comment.
at any time. at any time.
- **A username** is 1 to 32 letters, digits and underscores, unique in any - **A username** is 1 to 32 letters, digits and underscores, unique in any
case. A page writes it in its stored case, and another case still resolves. case. A page writes it in its stored case, and another case still resolves.
A user may rename themselves once every 30 days from `/settings`; those Users can't rename themselves: those with `user:manage` rename anyone, from
with `user:manage` rename anyone, at any time, from `/@<name>/manage`. `/@<name>/manage`.
- **Aliases.** A slug or a name left behind stays an alias for good, which - **Aliases.** A slug or a name left behind stays an alias for good, which
always names the item or user itself, so redirects never chain. Nobody else always names the item or user itself, so redirects never chain. Nobody else
can take one. An item can take back one of its own. Deleting an item frees can take one. An item can take back one of its own. Deleting an item frees
@@ -84,7 +84,7 @@ thumbnail, cover or attachment takes the owner's media token as `?token=`.
### me ### me
- `GET /api/me` — signed in. The caller's own user. - `GET /api/me` — signed in. The caller's own user.
- `PATCH /api/me` — signed in. Changes the description, email and username. - `PATCH /api/me` — signed in. Changes the description and email.
- `GET /api/me/media-token` — signed in. A media token. - `GET /api/me/media-token` — signed in. A media token.
- `GET`, `PATCH /api/me/settings` — signed in. The caller's settings. - `GET`, `PATCH /api/me/settings` — signed in. The caller's settings.
- `PUT /api/me/password` — signed in, with the current one. A new password, - `PUT /api/me/password` — signed in, with the current one. A new password,
@@ -115,7 +115,7 @@ thumbnail, cover or attachment takes the owner's media token as `?token=`.
follow. follow.
- `GET /api/users/:key/invitees` — anyone. The tree of users they invited. - `GET /api/users/:key/invitees` — anyone. The tree of users they invited.
- `PATCH /api/users/:key/role`, `/username` — `user:manage`. Another user's - `PATCH /api/users/:key/role`, `/username` — `user:manage`. Another user's
role, or anyone's name. role, or anyone's name, theirs included.
### dumps ### dumps

View File

@@ -6,9 +6,9 @@
* `routes/avatars.ts`. * `routes/avatars.ts`.
* *
* - `GET /api/me` — signed in. Answers the caller's own user. * - `GET /api/me` — signed in. Answers the caller's own user.
* - `PATCH /api/me` — signed in. Changes the caller's description, email and * - `PATCH /api/me` — signed in. Changes the caller's description and email,
* username, and answers their user. A new username keeps the old one as an * and answers their user. A username changes only by `user:manage`, through
* alias, at most once in 30 days but for those with `user:manage`. * `PATCH /api/users/:key/username`.
* - `GET /api/me/media-token` — signed in. Answers a media token, for a * - `GET /api/me/media-token` — signed in. Answers a media token, for a
* session that predates them. * session that predates them.
* - `GET /api/me/settings` — signed in. Answers the caller's settings. * - `GET /api/me/settings` — signed in. Answers the caller's settings.
@@ -38,19 +38,11 @@ import { isUpdateSettingsRequest } from "../validators/settings.ts";
import { toPublicUser, toSelfUser } from "../model/user.ts"; import { toPublicUser, toSelfUser } from "../model/user.ts";
import { createMediaToken, issueTokens } from "../lib/jwt.ts"; import { createMediaToken, issueTokens } from "../lib/jwt.ts";
import { verifyPassword } from "../lib/password.ts"; import { verifyPassword } from "../lib/password.ts";
import { can } from "../lib/permissions.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
import { import { getUserById, updateUser } from "../services/user-service.ts";
getUserById,
renameUser,
updateUser,
} from "../services/user-service.ts";
import { getSettings, updateSettings } from "../services/settings-service.ts"; import { getSettings, updateSettings } from "../services/settings-service.ts";
import { getPlaylistMembershipsForDump } from "../services/playlist-service.ts"; import { getPlaylistMembershipsForDump } from "../services/playlist-service.ts";
import { import { broadcastUserUpdated } from "../services/ws-service.ts";
broadcastUserUpdated,
updateClientUsername,
} from "../services/ws-service.ts";
import { VALIDATION } from "../../shared/validation.ts"; import { VALIDATION } from "../../shared/validation.ts";
/** /**
@@ -74,16 +66,7 @@ router.patch("/", authMiddleware, async (ctx: AuthContext) => {
} }
const userId = ctx.state.user.userId; const userId = ctx.state.user.userId;
// Only these fields: a stolen token alone must not change the role or the // Only these fields: a stolen token alone must not change the role or the
// password, which needs the current one. The rename first: it is the change // password, which needs the current one.
// most likely refused, and a refusal should leave the rest as it was.
if (body.username !== undefined) {
const renamed = renameUser(
userId,
body.username,
can(ctx.state.user, "user:manage"),
);
updateClientUsername(userId, renamed.username);
}
const updated = await updateUser(userId, { const updated = await updateUser(userId, {
description: body.description, description: body.description,
email: body.email, email: body.email,

View File

@@ -31,7 +31,7 @@
* - `PATCH /api/users/:key/role` — permission `user:manage`. Sets another * - `PATCH /api/users/:key/role` — permission `user:manage`. Sets another
* user's role. * user's role.
* - `PATCH /api/users/:key/username` — permission `user:manage`. Renames a * - `PATCH /api/users/:key/username` — permission `user:manage`. Renames a
* user, with no rate limit. * user, the caller included. It is the only way a name changes.
* *
* @module * @module
*/ */
@@ -293,7 +293,7 @@ router.patch(
throw new APIException(400, "Invalid request"); throw new APIException(400, "Invalid request");
} }
const user = resolveUserKey(ctx.params.key); const user = resolveUserKey(ctx.params.key);
const publicUser = toPublicUser(renameUser(user.id, username, true)); const publicUser = toPublicUser(renameUser(user.id, username));
updateClientUsername(publicUser.id, publicUser.username); updateClientUsername(publicUser.id, publicUser.username);
broadcastUserUpdated(publicUser); broadcastUserUpdated(publicUser);
const responseBody: APIResponse<PublicUser> = { const responseBody: APIResponse<PublicUser> = {

View File

@@ -32,11 +32,7 @@ import { LIKE, likePattern } from "../lib/like.ts";
import { parseRows } from "../model/rows.ts"; import { parseRows } from "../model/rows.ts";
import { isEmailEnabled, sendEmail } from "./email-service.ts"; import { isEmailEnabled, sendEmail } from "./email-service.ts";
import { renderWelcomeEmail } from "../mail/welcome.ts"; import { renderWelcomeEmail } from "../mail/welcome.ts";
import { import { USERNAME_RE, VALIDATION } from "../../shared/validation.ts";
RENAME_LIMITS,
USERNAME_RE,
VALIDATION,
} from "../../shared/validation.ts";
/** Selects full `UserRow`s from `users u`, with the inviter's name joined. */ /** Selects full `UserRow`s from `users u`, with the inviter's name joined. */
const USER_SELECT = const USER_SELECT =
@@ -206,24 +202,18 @@ export function resolveUserKey(key: string): User {
} }
/** /**
* Renames user `userId` to `username`, for `PATCH /api/me` and, by a * Renames user `userId` to `username`, for `PATCH /api/users/:key/username`,
* moderator, `PATCH /api/users/:key/username`, and returns them renamed. The * which only `user:manage` may call: users can't rename themselves. Returns
* old name becomes an alias of theirs for good, so `/@old/…` still reaches * them renamed. The old name becomes an alias of theirs for good, so
* them and nobody else may take it; a change of case alone leaves none, since * `/@old/…` still reaches them and nobody else may take it; a change of case
* names are looked up in any case. Taking back a former name of one's own is * alone leaves none, since names are looked up in any case. Taking back a
* allowed. Their dumps' and playlists' slugs belong to their id, so their * former name of their own is allowed. Their dumps' and playlists' slugs
* paths follow the new name. Unless `exempt` (a moderator), a user may change * belong to their id, so their paths follow the new name.
* names once in `RENAME_LIMITS.USERNAME`'s window.
* *
* @throws {APIException} 400 when `username` isn't a valid name, 404 when * @throws {APIException} 400 when `username` isn't a valid name, 404 when
* there is no such user, 409 when the name is or was someone else's, 429 when * there is no such user, 409 when the name is or was someone else's.
* the user changed names too recently.
*/ */
export function renameUser( export function renameUser(userId: string, username: string): User {
userId: string,
username: string,
exempt: boolean,
): User {
if (!USERNAME_RE.test(username)) { if (!USERNAME_RE.test(username)) {
throw new APIException( throw new APIException(
400, 400,
@@ -233,33 +223,19 @@ export function renameUser(
const user = getUserById(userId); const user = getUserById(userId);
if (username === user.username) return user; if (username === user.username) return user;
const now = new Date(); const now = new Date().toISOString();
transaction(() => { transaction(() => {
assertUsernameAvailable(username, userId); assertUsernameAvailable(username, userId);
if (username.toLowerCase() !== user.username.toLowerCase()) { if (username.toLowerCase() !== user.username.toLowerCase()) {
const { count, windowMs } = RENAME_LIMITS.USERNAME;
const recent = (db.prepare(
`SELECT COUNT(*) AS n FROM user_aliases WHERE user_id = ? AND created_at > ?;`,
).get(userId, new Date(now.getTime() - windowMs).toISOString()) as {
n: number;
}).n;
if (!exempt && recent >= count) {
throw new APIException(
429,
`A username can only be changed once every ${
windowMs / 86_400_000
} days`,
);
}
db.prepare( db.prepare(
`DELETE FROM user_aliases WHERE user_id = ? AND lower(username) = lower(?);`, `DELETE FROM user_aliases WHERE user_id = ? AND lower(username) = lower(?);`,
).run(userId, username); ).run(userId, username);
db.prepare( db.prepare(
`INSERT INTO user_aliases (username, user_id, created_at) VALUES (?, ?, ?);`, `INSERT INTO user_aliases (username, user_id, created_at) VALUES (?, ?, ?);`,
).run(user.username, userId, now.toISOString()); ).run(user.username, userId, now);
} }
db.prepare(`UPDATE users SET username = ?, updated_at = ? WHERE id = ?;`) db.prepare(`UPDATE users SET username = ?, updated_at = ? WHERE id = ?;`)
.run(username, now.toISOString(), userId); .run(username, now, userId);
}); });
return getUserById(userId); return getUserById(userId);
} }
@@ -342,10 +318,9 @@ export function searchUsersGlobal(query: string, limit: number): PublicUser[] {
* Applies `request` to user `userId` and returns the updated user. It trusts * Applies `request` to user `userId` and returns the updated user. It trusts
* `request`: the routes pick the fields a caller may change. The username * `request`: the routes pick the fields a caller may change. The username
* changes through {@link renameUser} alone, which keeps the old one as an * changes through {@link renameUser} alone, which keeps the old one as an
* alias, so a `username` here is ignored. A new password * alias. A new password also ends every session opened before it and closes
* also ends every session opened before it and closes the user's sockets, so * the user's sockets, so the caller must issue fresh tokens if the user is to
* the caller must issue fresh tokens if the user is to stay signed in. A * stay signed in. A `description` of null keeps the current one.
* `description` of null keeps the current one.
* *
* @throws {APIException} 404 when there is no such user, 409 when the new * @throws {APIException} 404 when there is no such user, 409 when the new
* email belongs to another account. * email belongs to another account.
@@ -356,8 +331,7 @@ export async function updateUser(
): Promise<User> { ): Promise<User> {
const user = getUserById(userId); const user = getUserById(userId);
const { password, description, email, username: _, ...requestFields } = const { password, description, email, ...requestFields } = request;
request;
if (email !== undefined && email !== user.email) { if (email !== undefined && email !== user.email) {
assertEmailAvailable(email, userId); assertEmailAvailable(email, userId);
} }

View File

@@ -23,7 +23,12 @@ import type {
SelfUser, SelfUser,
UserSuggestion, UserSuggestion,
} from "../../../shared/user.ts"; } from "../../../shared/user.ts";
import { assert, assertEquals, assertFalse } from "@std/assert"; import {
assert,
assertEquals,
assertFalse,
assertNotEquals,
} from "@std/assert";
import { import {
createPasswordResetToken, createPasswordResetToken,
verifyJWT, verifyJWT,
@@ -69,7 +74,7 @@ test("public profile: by @name in any case or id, without private fields", async
assertFalse("passwordHash" in me.data); assertFalse("passwordHash" in me.data);
}); });
test("PATCH /api/me: description, email and username only", async () => { test("PATCH /api/me: description and email only", async () => {
const user = await createUser(); const user = await createUser();
const taken = await createUser(); const taken = await createUser();
const res = await patch<SelfUser>("/api/me", { const res = await patch<SelfUser>("/api/me", {
@@ -618,17 +623,19 @@ test("settings: signed in only, and a bad value or unknown key is refused", asyn
// ── Usernames and paths ────────────────────────────────────────────────────── // ── Usernames and paths ──────────────────────────────────────────────────────
test("renaming: the old name redirects for good, and nobody else can take it", async () => { test("renaming: the old name redirects for good, and nobody else can take it", async () => {
const admin = await createUser({ role: "admin" });
const bob = await createUser(); const bob = await createUser();
const old = bob.username; const old = bob.username;
const renamed = uniqueName("robert"); const renamed = uniqueName("robert");
const dump = insertDump({ userId: bob.id, title: "Never gonna" }); const dump = insertDump({ userId: bob.id, title: "Never gonna" });
const playlist = insertPlaylist({ userId: bob.id, title: "Sunday mix" }); const playlist = insertPlaylist({ userId: bob.id, title: "Sunday mix" });
const rename = (key: string, username: string) =>
const res = await patch<SelfUser>("/api/me", { patch<PublicUser>(`/api/users/${key}/username`, {
auth: bob.auth, auth: admin.auth,
json: { username: renamed }, json: { username },
}); });
assertEquals(res.data.username, renamed);
assertEquals((await rename(bob.id, renamed)).data.username, renamed);
// The old name, in any case, still answers the user, under the new one. // The old name, in any case, still answers the user, under the new one.
for (const name of [old, old.toUpperCase(), renamed]) { for (const name of [old, old.toUpperCase(), renamed]) {
@@ -648,7 +655,7 @@ test("renaming: the old name redirects for good, and nobody else can take it", a
assertEquals([p.data.id, p.data.ownerUsername], [playlist.id, renamed]); assertEquals([p.data.id, p.data.ownerUsername], [playlist.id, renamed]);
} }
// Nobody can register or rename to the old name, in any case. // Nobody can register or be renamed to the old name, in any case.
const other = await createUser(); const other = await createUser();
const register = await post("/api/users", { const register = await post("/api/users", {
json: { json: {
@@ -659,19 +666,9 @@ test("renaming: the old name redirects for good, and nobody else can take it", a
}, },
}); });
assertEquals(register.status, 409); assertEquals(register.status, 409);
assertEquals( assertEquals((await rename(other.id, old)).status, 409);
(await patch("/api/me", { auth: other.auth, json: { username: old } }))
.status,
409,
);
// Nor to a current one. // Nor to a current one.
assertEquals( assertEquals((await rename(other.id, renamed)).status, 409);
(await patch("/api/me", {
auth: other.auth,
json: { username: renamed },
})).status,
409,
);
// Signing in takes the current name only. // Signing in takes the current name only.
assertEquals( assertEquals(
(await post("/api/sessions", { (await post("/api/sessions", {
@@ -681,60 +678,43 @@ test("renaming: the old name redirects for good, and nobody else can take it", a
); );
}); });
test("renaming: once in 30 days, a case change aside, and a moderator is exempt", async () => { test("renaming: only by user:manage, at any time, and never by oneself", async () => {
const user = await createUser(); const user = await createUser();
const first = uniqueName("first"); // PATCH /api/me refuses a name, and changes nothing else with it.
assertEquals( const self = await patch("/api/me", {
(await patch("/api/me", { auth: user.auth, json: { username: first } }))
.status,
200,
);
// A change of case alone leaves no alias, so it isn't counted.
assertEquals(
(await patch<SelfUser>("/api/me", {
auth: user.auth, auth: user.auth,
json: { username: first.toUpperCase() }, json: { username: uniqueName("self"), description: "hi" },
})).data.username,
first.toUpperCase(),
);
const again = await patch("/api/me", {
auth: user.auth,
json: { username: uniqueName("second") },
}); });
assertEquals(again.status, 429); assertEquals(self.status, 400);
assertEquals( const me = await get<SelfUser>("/api/me", { auth: user.auth });
(await patch("/api/me", { auth: user.auth, json: { username: "a b" } })) assertEquals(me.data.username, user.username);
.status, assertNotEquals(me.data.description, "hi");
400,
);
// An admin renames anyone at any time, and themselves too. // An admin renames anyone at any time, themselves too.
const admin = await createUser({ role: "admin" }); const admin = await createUser({ role: "admin" });
const third = uniqueName("third"); const rename = (key: string, username: string) =>
assertEquals( patch<PublicUser>(`/api/users/${key}/username`, {
(await patch<PublicUser>(`/api/users/${user.id}/username`, {
auth: admin.auth, auth: admin.auth,
json: { username: third }, json: { username },
})).data.username, });
third, const first = uniqueName("first");
const second = uniqueName("second");
assertEquals((await rename(user.id, first)).data.username, first);
assertEquals((await rename(user.id, second)).data.username, second);
// A change of case alone leaves no alias.
assertEquals(
(await rename(`@${second}`, second.toUpperCase())).data.username,
second.toUpperCase(),
); );
assertEquals( assertEquals(
(await patch<PublicUser>(`/api/users/@${third}/username`, { (await rename(`@${first}`, first)).data.username,
auth: admin.auth,
json: { username: first },
})).data.username,
first, first,
"a former name of one's own can be taken back", "a former name of one's own can be taken back",
); );
for (let i = 0; i < 2; i++) { assertEquals((await rename(user.id, "a b")).status, 400);
assertEquals( const adminName = uniqueName("admin");
(await patch("/api/me", { assertEquals((await rename(admin.id, adminName)).data.username, adminName);
auth: admin.auth,
json: { username: uniqueName("admin") },
})).status,
200,
);
}
// Others may not. // Others may not.
const moderator = await createUser({ role: "moderator" }); const moderator = await createUser({ role: "moderator" });
assertEquals( assertEquals(

View File

@@ -44,7 +44,8 @@ Deno.test("isUpdateUserRequest", () => {
assert(isUpdateUserRequest({ description: "hi", email: "a@b.co" })); assert(isUpdateUserRequest({ description: "hi", email: "a@b.co" }));
assertFalse(isUpdateUserRequest({ description: "x".repeat(2001) })); assertFalse(isUpdateUserRequest({ description: "x".repeat(2001) }));
assertFalse(isUpdateUserRequest({ email: "nope" })); assertFalse(isUpdateUserRequest({ email: "nope" }));
assertFalse(isUpdateUserRequest({ username: "no spaces" })); // A name changes only through a moderator's PATCH /api/users/:key/username.
assertFalse(isUpdateUserRequest({ username: "well_formed" }));
assertFalse(isUpdateUserRequest({ password: "short" })); assertFalse(isUpdateUserRequest({ password: "short" }));
assertFalse(isUpdateUserRequest({ role: "root" })); assertFalse(isUpdateUserRequest({ role: "root" }));
assertFalse(isUpdateUserRequest("string")); assertFalse(isUpdateUserRequest("string"));

View File

@@ -81,10 +81,9 @@ export function validateRegisterUserRequest(obj: unknown): string | null {
export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
if (!obj || typeof obj !== "object") return false; if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>; const o = obj as Record<string, unknown>;
if ("username" in o) { // A user can't rename themselves: a moderator does, through
if (typeof o.username !== "string") return false; // `PATCH /api/users/:key/username`.
if (!USERNAME_RE.test(o.username as string)) return false; if ("username" in o) return false;
}
if ("password" in o) { if ("password" in o) {
if (typeof o.password !== "string") return false; if (typeof o.password !== "string") return false;
const len = (o.password as string).length; const len = (o.password as string).length;

File diff suppressed because one or more lines are too long

View File

@@ -120,13 +120,13 @@ msgstr "yours"
#. js-lingui-explicit-id #. js-lingui-explicit-id
#: api/mail/digest.ts:221 #: api/mail/digest.ts:221
#: src/pages/Settings.tsx:190 #: src/pages/Settings.tsx:157
msgid "digest.monthly" msgid "digest.monthly"
msgstr "Monthly recap" msgstr "Monthly recap"
#. js-lingui-explicit-id #. js-lingui-explicit-id
#: api/mail/digest.ts:221 #: api/mail/digest.ts:221
#: src/pages/Settings.tsx:181 #: src/pages/Settings.tsx:148
msgid "digest.weekly" msgid "digest.weekly"
msgstr "Weekly digest" msgstr "Weekly digest"
@@ -501,7 +501,7 @@ msgstr "A link is required"
msgid "a post" msgid "a post"
msgstr "a post" msgstr "a post"
#: src/pages/Settings.tsx:108 #: src/pages/Settings.tsx:86
msgid "Account" msgid "Account"
msgstr "Account" msgstr "Account"
@@ -550,7 +550,7 @@ msgstr "Already have an account? <0>Log in</0>"
msgid "An unexpected error occurred." msgid "An unexpected error occurred."
msgstr "An unexpected error occurred." msgstr "An unexpected error occurred."
#: src/pages/Settings.tsx:135 #: src/pages/Settings.tsx:102
msgid "Appearance" msgid "Appearance"
msgstr "Appearance" msgstr "Appearance"
@@ -565,7 +565,7 @@ msgstr "At least {0} characters"
msgid "At most {maxLength} characters." msgid "At most {maxLength} characters."
msgstr "At most {maxLength} characters." msgstr "At most {maxLength} characters."
#: src/pages/Settings.tsx:51 #: src/pages/Settings.tsx:44
msgid "Auto" msgid "Auto"
msgstr "Auto" msgstr "Auto"
@@ -629,7 +629,7 @@ msgstr "Change image"
msgid "Change password" msgid "Change password"
msgstr "Change password" msgstr "Change password"
#: src/pages/Settings.tsx:130 #: src/pages/Settings.tsx:97
msgid "Change password…" msgid "Change password…"
msgstr "Change password…" msgstr "Change password…"
@@ -678,7 +678,7 @@ msgstr "Close without posting? The attached file won't be kept — everything yo
msgid "Collapse player" msgid "Collapse player"
msgstr "Collapse player" msgstr "Collapse player"
#: src/pages/Settings.tsx:144 #: src/pages/Settings.tsx:111
msgid "Color scheme" msgid "Color scheme"
msgstr "Color scheme" msgstr "Color scheme"
@@ -752,11 +752,11 @@ msgstr "Creating…"
msgid "Current password" msgid "Current password"
msgstr "Current password" msgstr "Current password"
#: src/pages/Settings.tsx:53 #: src/pages/Settings.tsx:46
msgid "Dark" msgid "Dark"
msgstr "Dark" msgstr "Dark"
#: src/pages/Settings.tsx:155 #: src/pages/Settings.tsx:122
msgid "Default tab" msgid "Default tab"
msgstr "Default tab" msgstr "Default tab"
@@ -944,7 +944,7 @@ msgstr "Editing"
msgid "Editing {0}" msgid "Editing {0}"
msgstr "Editing {0}" msgstr "Editing {0}"
#: src/pages/Settings.tsx:179 #: src/pages/Settings.tsx:146
msgid "Email" msgid "Email"
msgstr "Email" msgstr "Email"
@@ -1017,7 +1017,7 @@ msgstr "Failed to update avatar"
msgid "Failed to update role" msgid "Failed to update role"
msgstr "Failed to update role" msgstr "Failed to update role"
#: src/pages/Settings.tsx:153 #: src/pages/Settings.tsx:120
msgid "Feeds" msgid "Feeds"
msgstr "Feeds" msgstr "Feeds"
@@ -1052,7 +1052,7 @@ msgstr "Follow some users to see their dumps here."
#: src/components/feed/FeedTabBar.tsx:43 #: src/components/feed/FeedTabBar.tsx:43
#: src/components/profile/ProfileFollowTab.tsx:163 #: src/components/profile/ProfileFollowTab.tsx:163
#: src/pages/Settings.tsx:60 #: src/pages/Settings.tsx:53
#: src/pages/UserPublicProfile.tsx:157 #: src/pages/UserPublicProfile.tsx:157
msgid "Followed" msgid "Followed"
msgstr "Followed" msgstr "Followed"
@@ -1087,7 +1087,7 @@ msgid "Go to login"
msgstr "Go to login" msgstr "Go to login"
#: src/components/feed/FeedTabBar.tsx:39 #: src/components/feed/FeedTabBar.tsx:39
#: src/pages/Settings.tsx:57 #: src/pages/Settings.tsx:50
msgid "Hot" msgid "Hot"
msgstr "Hot" msgstr "Hot"
@@ -1132,7 +1132,7 @@ msgid "Invitees"
msgstr "Invitees" msgstr "Invitees"
#: src/components/feed/FeedTabBar.tsx:41 #: src/components/feed/FeedTabBar.tsx:41
#: src/pages/Settings.tsx:59 #: src/pages/Settings.tsx:52
msgid "Journal" msgid "Journal"
msgstr "Journal" msgstr "Journal"
@@ -1144,7 +1144,7 @@ msgstr "Keep editing"
msgid "Letters, numbers and underscores only" msgid "Letters, numbers and underscores only"
msgstr "Letters, numbers and underscores only" msgstr "Letters, numbers and underscores only"
#: src/pages/Settings.tsx:52 #: src/pages/Settings.tsx:45
msgid "Light" msgid "Light"
msgstr "Light" msgstr "Light"
@@ -1276,7 +1276,7 @@ msgstr "new"
#: src/components/chat/ChatModal.tsx:669 #: src/components/chat/ChatModal.tsx:669
#: src/components/feed/FeedTabBar.tsx:40 #: src/components/feed/FeedTabBar.tsx:40
#: src/pages/Settings.tsx:58 #: src/pages/Settings.tsx:51
msgid "New" msgid "New"
msgstr "New" msgstr "New"
@@ -1368,7 +1368,7 @@ msgstr "Nothing here yet."
#: src/components/layout/NotificationBell.tsx:54 #: src/components/layout/NotificationBell.tsx:54
#: src/pages/Notifications.tsx:308 #: src/pages/Notifications.tsx:308
#: src/pages/Notifications.tsx:364 #: src/pages/Notifications.tsx:364
#: src/pages/Settings.tsx:164 #: src/pages/Settings.tsx:131
msgid "Notifications" msgid "Notifications"
msgstr "Notifications" msgstr "Notifications"
@@ -1376,11 +1376,11 @@ msgstr "Notifications"
msgid "Notifications ({unreadNotificationCount} unread)" msgid "Notifications ({unreadNotificationCount} unread)"
msgstr "Notifications ({unreadNotificationCount} unread)" msgstr "Notifications ({unreadNotificationCount} unread)"
#: src/pages/Settings.tsx:65 #: src/pages/Settings.tsx:58
msgid "Off" msgid "Off"
msgstr "Off" msgstr "Off"
#: src/pages/Settings.tsx:64 #: src/pages/Settings.tsx:57
msgid "On" msgid "On"
msgstr "On" msgstr "On"
@@ -1413,7 +1413,7 @@ msgstr "Overview"
msgid "Page not found" msgid "Page not found"
msgstr "Page not found" msgstr "Page not found"
#: src/pages/Settings.tsx:124 #: src/pages/Settings.tsx:91
#: src/pages/UserLogin.tsx:82 #: src/pages/UserLogin.tsx:82
msgid "Password" msgid "Password"
msgstr "Password" msgstr "Password"
@@ -1690,8 +1690,8 @@ msgstr "Set new password"
#: src/components/layout/UserMenu.tsx:89 #: src/components/layout/UserMenu.tsx:89
#: src/components/profile/ProfileHeader.tsx:177 #: src/components/profile/ProfileHeader.tsx:177
#: src/pages/Settings.tsx:77 #: src/pages/Settings.tsx:69
#: src/pages/Settings.tsx:105 #: src/pages/Settings.tsx:83
msgid "Settings" msgid "Settings"
msgstr "Settings" msgstr "Settings"
@@ -1718,7 +1718,7 @@ msgstr "Some images couldn't be uploaded."
msgid "Something went wrong" msgid "Something went wrong"
msgstr "Something went wrong" msgstr "Something went wrong"
#: src/pages/Settings.tsx:166 #: src/pages/Settings.tsx:133
msgid "Sounds" msgid "Sounds"
msgstr "Sounds" msgstr "Sounds"
@@ -1726,7 +1726,7 @@ msgstr "Sounds"
msgid "Start fresh" msgid "Start fresh"
msgstr "Start fresh" msgstr "Start fresh"
#: src/pages/Settings.tsx:137 #: src/pages/Settings.tsx:104
msgid "Style" msgid "Style"
msgstr "Style" msgstr "Style"
@@ -1778,7 +1778,7 @@ msgstr "This page does not exist."
msgid "This reset link is missing or malformed." msgid "This reset link is missing or malformed."
msgstr "This reset link is missing or malformed." msgstr "This reset link is missing or malformed."
#: src/pages/Settings.tsx:202 #: src/pages/Settings.tsx:169
msgid "This site doesn't send email, so no digest goes out." msgid "This site doesn't send email, so no digest goes out."
msgstr "This site doesn't send email, so no digest goes out." msgstr "This site doesn't send email, so no digest goes out."
@@ -1915,7 +1915,7 @@ msgstr "User menu"
#: src/components/profile/ProfileManageTab.tsx:61 #: src/components/profile/ProfileManageTab.tsx:61
#: src/components/profile/UsernameField.tsx:55 #: src/components/profile/UsernameField.tsx:55
#: src/pages/Settings.tsx:110 #: src/pages/Settings.tsx:88
#: src/pages/UserLogin.tsx:75 #: src/pages/UserLogin.tsx:75
#: src/pages/UserRegister.tsx:137 #: src/pages/UserRegister.tsx:137
msgid "Username" msgid "Username"
@@ -1970,10 +1970,6 @@ msgstr "Write a reply…"
msgid "Yesterday" msgid "Yesterday"
msgstr "Yesterday" msgstr "Yesterday"
#: src/pages/Settings.tsx:116
msgid "You can change it once every {renameDays} days. Links to your old name keep working, and nobody else can take it."
msgstr "You can change it once every {renameDays} days. Links to your old name keep working, and nobody else can take it."
#: src/components/dump/create/DuplicateNotice.tsx:61 #: src/components/dump/create/DuplicateNotice.tsx:61
msgid "You can post it anyway." msgid "You can post it anyway."
msgstr "You can post it anyway." msgstr "You can post it anyway."

File diff suppressed because one or more lines are too long

View File

@@ -120,13 +120,13 @@ msgstr "à vous"
#. js-lingui-explicit-id #. js-lingui-explicit-id
#: api/mail/digest.ts:221 #: api/mail/digest.ts:221
#: src/pages/Settings.tsx:190 #: src/pages/Settings.tsx:157
msgid "digest.monthly" msgid "digest.monthly"
msgstr "Récap mensuel" msgstr "Récap mensuel"
#. js-lingui-explicit-id #. js-lingui-explicit-id
#: api/mail/digest.ts:221 #: api/mail/digest.ts:221
#: src/pages/Settings.tsx:181 #: src/pages/Settings.tsx:148
msgid "digest.weekly" msgid "digest.weekly"
msgstr "Résumé hebdomadaire" msgstr "Résumé hebdomadaire"
@@ -501,7 +501,7 @@ msgstr "Il faut un lien"
msgid "a post" msgid "a post"
msgstr "une publication" msgstr "une publication"
#: src/pages/Settings.tsx:108 #: src/pages/Settings.tsx:86
msgid "Account" msgid "Account"
msgstr "Compte" msgstr "Compte"
@@ -550,7 +550,7 @@ msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
msgid "An unexpected error occurred." msgid "An unexpected error occurred."
msgstr "Une erreur inattendue s'est produite." msgstr "Une erreur inattendue s'est produite."
#: src/pages/Settings.tsx:135 #: src/pages/Settings.tsx:102
msgid "Appearance" msgid "Appearance"
msgstr "Apparence" msgstr "Apparence"
@@ -565,7 +565,7 @@ msgstr "Au moins {0} caractères"
msgid "At most {maxLength} characters." msgid "At most {maxLength} characters."
msgstr "{maxLength} caractères au maximum." msgstr "{maxLength} caractères au maximum."
#: src/pages/Settings.tsx:51 #: src/pages/Settings.tsx:44
msgid "Auto" msgid "Auto"
msgstr "Auto" msgstr "Auto"
@@ -629,7 +629,7 @@ msgstr "Changer l'image"
msgid "Change password" msgid "Change password"
msgstr "Changer le mot de passe" msgstr "Changer le mot de passe"
#: src/pages/Settings.tsx:130 #: src/pages/Settings.tsx:97
msgid "Change password…" msgid "Change password…"
msgstr "Changer le mot de passe…" msgstr "Changer le mot de passe…"
@@ -678,7 +678,7 @@ msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez
msgid "Collapse player" msgid "Collapse player"
msgstr "Réduire le lecteur" msgstr "Réduire le lecteur"
#: src/pages/Settings.tsx:144 #: src/pages/Settings.tsx:111
msgid "Color scheme" msgid "Color scheme"
msgstr "Thème de couleur" msgstr "Thème de couleur"
@@ -752,11 +752,11 @@ msgstr "Création…"
msgid "Current password" msgid "Current password"
msgstr "Mot de passe actuel" msgstr "Mot de passe actuel"
#: src/pages/Settings.tsx:53 #: src/pages/Settings.tsx:46
msgid "Dark" msgid "Dark"
msgstr "Sombre" msgstr "Sombre"
#: src/pages/Settings.tsx:155 #: src/pages/Settings.tsx:122
msgid "Default tab" msgid "Default tab"
msgstr "Onglet par défaut" msgstr "Onglet par défaut"
@@ -944,7 +944,7 @@ msgstr "Modification"
msgid "Editing {0}" msgid "Editing {0}"
msgstr "Édition de {0}" msgstr "Édition de {0}"
#: src/pages/Settings.tsx:179 #: src/pages/Settings.tsx:146
msgid "Email" msgid "Email"
msgstr "E-mail" msgstr "E-mail"
@@ -1017,7 +1017,7 @@ msgstr "Impossible de mettre à jour l'avatar"
msgid "Failed to update role" msgid "Failed to update role"
msgstr "Erreur lors de la mise à jour du rôle" msgstr "Erreur lors de la mise à jour du rôle"
#: src/pages/Settings.tsx:153 #: src/pages/Settings.tsx:120
msgid "Feeds" msgid "Feeds"
msgstr "Flux" msgstr "Flux"
@@ -1052,7 +1052,7 @@ msgstr "Suivez des utilisateurs pour voir leurs recos ici."
#: src/components/feed/FeedTabBar.tsx:43 #: src/components/feed/FeedTabBar.tsx:43
#: src/components/profile/ProfileFollowTab.tsx:163 #: src/components/profile/ProfileFollowTab.tsx:163
#: src/pages/Settings.tsx:60 #: src/pages/Settings.tsx:53
#: src/pages/UserPublicProfile.tsx:157 #: src/pages/UserPublicProfile.tsx:157
msgid "Followed" msgid "Followed"
msgstr "Suivi" msgstr "Suivi"
@@ -1087,7 +1087,7 @@ msgid "Go to login"
msgstr "Aller à la connexion" msgstr "Aller à la connexion"
#: src/components/feed/FeedTabBar.tsx:39 #: src/components/feed/FeedTabBar.tsx:39
#: src/pages/Settings.tsx:57 #: src/pages/Settings.tsx:50
msgid "Hot" msgid "Hot"
msgstr "Tendances" msgstr "Tendances"
@@ -1132,7 +1132,7 @@ msgid "Invitees"
msgstr "Invités" msgstr "Invités"
#: src/components/feed/FeedTabBar.tsx:41 #: src/components/feed/FeedTabBar.tsx:41
#: src/pages/Settings.tsx:59 #: src/pages/Settings.tsx:52
msgid "Journal" msgid "Journal"
msgstr "Journal" msgstr "Journal"
@@ -1144,7 +1144,7 @@ msgstr "Continuer l'édition"
msgid "Letters, numbers and underscores only" msgid "Letters, numbers and underscores only"
msgstr "Lettres, chiffres et tirets bas seulement" msgstr "Lettres, chiffres et tirets bas seulement"
#: src/pages/Settings.tsx:52 #: src/pages/Settings.tsx:45
msgid "Light" msgid "Light"
msgstr "Clair" msgstr "Clair"
@@ -1276,7 +1276,7 @@ msgstr "nouveau"
#: src/components/chat/ChatModal.tsx:669 #: src/components/chat/ChatModal.tsx:669
#: src/components/feed/FeedTabBar.tsx:40 #: src/components/feed/FeedTabBar.tsx:40
#: src/pages/Settings.tsx:58 #: src/pages/Settings.tsx:51
msgid "New" msgid "New"
msgstr "Nouveau" msgstr "Nouveau"
@@ -1368,7 +1368,7 @@ msgstr "Rien ici pour l'instant."
#: src/components/layout/NotificationBell.tsx:54 #: src/components/layout/NotificationBell.tsx:54
#: src/pages/Notifications.tsx:308 #: src/pages/Notifications.tsx:308
#: src/pages/Notifications.tsx:364 #: src/pages/Notifications.tsx:364
#: src/pages/Settings.tsx:164 #: src/pages/Settings.tsx:131
msgid "Notifications" msgid "Notifications"
msgstr "Notifications" msgstr "Notifications"
@@ -1376,11 +1376,11 @@ msgstr "Notifications"
msgid "Notifications ({unreadNotificationCount} unread)" msgid "Notifications ({unreadNotificationCount} unread)"
msgstr "Notifications ({unreadNotificationCount} non lues)" msgstr "Notifications ({unreadNotificationCount} non lues)"
#: src/pages/Settings.tsx:65 #: src/pages/Settings.tsx:58
msgid "Off" msgid "Off"
msgstr "Désactivés" msgstr "Désactivés"
#: src/pages/Settings.tsx:64 #: src/pages/Settings.tsx:57
msgid "On" msgid "On"
msgstr "Activés" msgstr "Activés"
@@ -1413,7 +1413,7 @@ msgstr "Aperçu"
msgid "Page not found" msgid "Page not found"
msgstr "Page introuvable" msgstr "Page introuvable"
#: src/pages/Settings.tsx:124 #: src/pages/Settings.tsx:91
#: src/pages/UserLogin.tsx:82 #: src/pages/UserLogin.tsx:82
msgid "Password" msgid "Password"
msgstr "Mot de passe" msgstr "Mot de passe"
@@ -1690,8 +1690,8 @@ msgstr "Définir un nouveau mot de passe"
#: src/components/layout/UserMenu.tsx:89 #: src/components/layout/UserMenu.tsx:89
#: src/components/profile/ProfileHeader.tsx:177 #: src/components/profile/ProfileHeader.tsx:177
#: src/pages/Settings.tsx:77 #: src/pages/Settings.tsx:69
#: src/pages/Settings.tsx:105 #: src/pages/Settings.tsx:83
msgid "Settings" msgid "Settings"
msgstr "Paramètres" msgstr "Paramètres"
@@ -1718,7 +1718,7 @@ msgstr "Certaines images n'ont pas pu être envoyées."
msgid "Something went wrong" msgid "Something went wrong"
msgstr "Une erreur est survenue" msgstr "Une erreur est survenue"
#: src/pages/Settings.tsx:166 #: src/pages/Settings.tsx:133
msgid "Sounds" msgid "Sounds"
msgstr "Sons" msgstr "Sons"
@@ -1726,7 +1726,7 @@ msgstr "Sons"
msgid "Start fresh" msgid "Start fresh"
msgstr "Repartir de zéro" msgstr "Repartir de zéro"
#: src/pages/Settings.tsx:137 #: src/pages/Settings.tsx:104
msgid "Style" msgid "Style"
msgstr "Style" msgstr "Style"
@@ -1778,7 +1778,7 @@ msgstr "Rien à voir, circulez."
msgid "This reset link is missing or malformed." msgid "This reset link is missing or malformed."
msgstr "Ce lien de réinitialisation est absent ou malformé." msgstr "Ce lien de réinitialisation est absent ou malformé."
#: src/pages/Settings.tsx:202 #: src/pages/Settings.tsx:169
msgid "This site doesn't send email, so no digest goes out." msgid "This site doesn't send email, so no digest goes out."
msgstr "Ce site n'envoie pas d'e-mails, aucun résumé ne part donc." msgstr "Ce site n'envoie pas d'e-mails, aucun résumé ne part donc."
@@ -1915,7 +1915,7 @@ msgstr "Menu utilisateur"
#: src/components/profile/ProfileManageTab.tsx:61 #: src/components/profile/ProfileManageTab.tsx:61
#: src/components/profile/UsernameField.tsx:55 #: src/components/profile/UsernameField.tsx:55
#: src/pages/Settings.tsx:110 #: src/pages/Settings.tsx:88
#: src/pages/UserLogin.tsx:75 #: src/pages/UserLogin.tsx:75
#: src/pages/UserRegister.tsx:137 #: src/pages/UserRegister.tsx:137
msgid "Username" msgid "Username"
@@ -1970,10 +1970,6 @@ msgstr "Écrire une réponse…"
msgid "Yesterday" msgid "Yesterday"
msgstr "Hier" msgstr "Hier"
#: src/pages/Settings.tsx:116
msgid "You can change it once every {renameDays} days. Links to your old name keep working, and nobody else can take it."
msgstr "Vous pouvez le changer une fois tous les {renameDays} jours. Les liens vers votre ancien nom marchent toujours, et personne d’autre ne peut le prendre."
#: src/components/dump/create/DuplicateNotice.tsx:61 #: src/components/dump/create/DuplicateNotice.tsx:61
msgid "You can post it anyway." msgid "You can post it anyway."
msgstr "Vous pouvez la publier quand même." msgstr "Vous pouvez la publier quand même."

View File

@@ -102,12 +102,11 @@ export interface RegisterUserRequest {
/** /**
* Passed to `updateUser` by the users routes and the password reset, and sent * Passed to `updateUser` by the users routes and the password reset, and sent
* by a client to `PATCH /api/me`, which takes only `description`, `email` and * by a client to `PATCH /api/me`, which takes only `description` and `email`;
* `username`; the role and the password change through their own endpoints, * the role and the password change through their own endpoints, and the
* and the username through `renameUser`. * username through `renameUser`, by a moderator only.
*/ */
export interface UpdateUserRequest { export interface UpdateUserRequest {
username?: string;
password?: string; password?: string;
role?: Role; role?: Role;
/** An empty string clears it. Null, like undefined, keeps it. */ /** An empty string clears it. Null, like undefined, keeps it. */

View File

@@ -38,14 +38,12 @@ export const USERNAME_RE: RegExp = new RegExp(
); );
/** /**
* How often a slug or a username may change, counted from the aliases the * How often a dump's or a playlist's slug may change, counted from the aliases
* changes left: a dump's or a playlist's slug 3 times a day, a username once * the changes left: 3 times a day. Moderators are exempt. slug-service refuses
* a month. Moderators are exempt. The services refuse a change past it with a * a change past it with a 429, and the edit forms say what the limit is.
* 429, and the forms say what the limit is.
*/ */
export const RENAME_LIMITS = { export const RENAME_LIMITS = {
SLUG: { count: 3, windowMs: 24 * 3_600_000 }, SLUG: { count: 3, windowMs: 24 * 3_600_000 },
USERNAME: { count: 1, windowMs: 30 * 24 * 3_600_000 },
} as const; } as const;
/** /**

View File

@@ -793,8 +793,11 @@
} }
/* ── Mode toggle — segmented control ── */ /* ── Mode toggle — segmented control ── */
/* Too wide for its container, it wraps onto more rows, each filled. */
.visibility-toggle { .visibility-toggle {
display: flex; display: flex;
flex-wrap: wrap;
max-width: 100%;
background: var(--color-bg); background: var(--color-bg);
border: 1.5px solid var(--color-border); border: 1.5px solid var(--color-border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@@ -816,6 +819,14 @@
white-space: nowrap; white-space: nowrap;
} }
/* On a phone, many options make two even rows; Segmented sets the columns. */
@media (max-width: 512px) {
.visibility-toggle--many {
display: grid;
grid-template-columns: repeat(var(--segmented-columns), minmax(0, 1fr));
}
}
.visibility-toggle button.active { .visibility-toggle button.active {
background: var(--color-accent); background: var(--color-accent);
color: var(--color-on-accent); color: var(--color-on-accent);
@@ -2646,17 +2657,32 @@ body.has-player .chat-fab {
border: 1.5px solid var(--color-border); border: 1.5px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: transparent; background: transparent;
color: var(--color-text-muted); color: var(--color-text);
font-size: var(--text-sm); font-size: var(--text-sm);
/* A <button> and a <Link> come out the same height. */
line-height: 1.5;
font-family: inherit; font-family: inherit;
cursor: pointer; cursor: pointer;
transition: border-color var(--dur-base), color var(--dur-base), background transition: border-color var(--dur-base), color var(--dur-base), background
var(--dur-base); var(--dur-base);
} }
/* Neutral: the accent can be a red (Smooth's), which would read as danger. */
.btn-border:hover { .btn-border:hover {
border-color: var(--color-text-muted);
color: var(--color-text);
background: color-mix(in srgb, var(--color-text) 6%, transparent);
}
/* Log out: the one bordered button that ends something, muted until hovered. */
.btn-border--danger {
color: var(--color-text-muted);
}
.btn-border--danger:hover {
border-color: var(--color-danger); border-color: var(--color-danger);
color: var(--color-danger); color: var(--color-danger);
background: transparent;
} }
/* ── ErrorCard ── */ /* ── ErrorCard ── */
@@ -7045,22 +7071,27 @@ body.has-player .chat-fab {
} }
/* ── Profile appearance settings ── */ /* ── Profile appearance settings ── */
/* Labels in one column, controls in the rest of the width. Each control keeps
its own width, up to the column's, past which a segmented control wraps. */
.profile-appearance-grid { .profile-appearance-grid {
display: grid; display: grid;
grid-template-columns: max-content max-content; grid-template-columns: max-content minmax(0, 1fr);
gap: 0.75rem 1rem; gap: 0.75rem 1rem;
align-items: center; align-items: center;
justify-items: start;
} }
.profile-appearance-row { .profile-appearance-row {
display: contents; display: contents;
} }
/* On a phone the label goes above its control, and the segmented controls
take the whole width, so they line up and a long one wraps evenly. */
@media (max-width: 512px) { @media (max-width: 512px) {
.profile-appearance-grid { .profile-appearance-grid {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: stretch;
gap: 1rem; gap: 1rem;
} }
@@ -7070,6 +7101,14 @@ body.has-player .chat-fab {
align-items: flex-start; align-items: flex-start;
gap: 0.3rem; gap: 0.3rem;
} }
.profile-appearance-row > .visibility-toggle {
align-self: stretch;
}
}
.profile-appearance-value {
font-weight: 600;
} }
.profile-appearance-label { .profile-appearance-label {

View File

@@ -181,7 +181,11 @@ export function ProfileHeader(
<Trans>Manage</Trans> <Trans>Manage</Trans>
</Link> </Link>
)} )}
<button type="button" className="btn-border" onClick={logout}> <button
type="button"
className="btn-border btn-border--danger"
onClick={logout}
>
<Trans>Log out</Trans> <Trans>Log out</Trans>
</button> </button>
</ProfileOwnActions> </ProfileOwnActions>

View File

@@ -1,6 +1,6 @@
/** /**
* Renders the field that renames a user, on the settings page for one's own * Renders the field that renames a user, on a profile's "manage" tab, which
* name and on a profile's "manage" tab for a moderator. * those with `user:manage` see: users can't rename themselves.
* *
* @module * @module
*/ */
@@ -14,16 +14,16 @@ import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { FormError } from "../form/FormError.tsx"; import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx"; import { SubmitButton } from "../form/SubmitButton.tsx";
/** Passed to {@link UsernameField} by `Settings` and `ProfileManageTab`. */ /** Passed to {@link UsernameField} by `ProfileManageTab`. */
export interface UsernameFieldProps { export interface UsernameFieldProps {
username: string; username: string;
/** /**
* Sends the new name, to `PATCH /api/me` or * Sends the new name to `PATCH /api/users/:key/username`, and resolves to
* `PATCH /api/users/:key/username`, and resolves to the name the server * the name the server kept. A refusal rejects with its message, which the
* kept. A refusal rejects with its message, which the field shows. * field shows.
*/ */
save: (username: string) => Promise<string>; save: (username: string) => Promise<string>;
/** 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; hint?: ReactNode;
} }

View File

@@ -29,12 +29,28 @@ export interface SegmentedProps<V> {
className?: string; 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. */ /** Renders the options as buttons, the one equal to `value` marked active. */
export function Segmented<V>( export function Segmented<V>(
{ options, value, onChange, disabled, className }: SegmentedProps<V>, { options, value, onChange, disabled, className }: SegmentedProps<V>,
): React.JSX.Element { ): React.JSX.Element {
const many = options.length > MAX_ONE_ROW;
return ( return (
<div className={`visibility-toggle${className ? ` ${className}` : ""}`}> <div
className={`visibility-toggle${many ? " visibility-toggle--many" : ""}${
className ? ` ${className}` : ""
}`}
style={many
? {
"--segmented-columns": Math.ceil(options.length / 2),
} as React.CSSProperties
: undefined}
>
{options.map((opt) => ( {options.map((opt) => (
<button <button
key={String(opt.value)} key={String(opt.value)}

View File

@@ -1,6 +1,6 @@
/** /**
* Renders the signed-in user's settings page, `/settings`, where they change * Renders the signed-in user's settings page, `/settings`, where they change
* their name, their password, their account's settings and this browser's. * their password, their account's settings and this browser's.
* *
* @module * @module
*/ */
@@ -13,9 +13,6 @@ import { Trans } from "@lingui/react/macro";
import { Trans as Message } from "@lingui/react"; import { Trans as Message } from "@lingui/react";
import type { ColorScheme, StyleName } from "../../shared/theme.ts"; import type { ColorScheme, StyleName } from "../../shared/theme.ts";
import type { FeedTab } from "../../shared/paths.ts"; import type { FeedTab } from "../../shared/paths.ts";
import type { Wire } from "../../shared/api.ts";
import type { SelfUser } from "../../shared/user.ts";
import { API_URL } from "../config/api.ts";
import { useTheme } from "../hooks/theme/useTheme.ts"; import { useTheme } from "../hooks/theme/useTheme.ts";
import { useDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts"; import { useDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts";
import { useSoundsEnabled } from "../hooks/shared/useSoundsEnabled.ts"; import { useSoundsEnabled } from "../hooks/shared/useSoundsEnabled.ts";
@@ -24,8 +21,6 @@ import { useRequiredAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { EMAIL_ENABLED } from "../config/instance.ts"; import { EMAIL_ENABLED } from "../config/instance.ts";
import { previewChime } from "../utils/shared/sound.ts"; import { previewChime } from "../utils/shared/sound.ts";
import { expectOk } from "../utils/shared/api.ts";
import { deserializeSelfUser } from "../utils/shared/deserialize.ts";
import { PageShell } from "../components/layout/PageShell.tsx"; import { PageShell } from "../components/layout/PageShell.tsx";
import { import {
Segmented, Segmented,
@@ -34,8 +29,6 @@ import {
import { ChangePasswordModal } from "../components/profile/ChangePasswordModal.tsx"; import { ChangePasswordModal } from "../components/profile/ChangePasswordModal.tsx";
import { ProfileSection } from "../components/profile/ProfileSection.tsx"; import { ProfileSection } from "../components/profile/ProfileSection.tsx";
import { SettingRow } from "../components/profile/SettingRow.tsx"; import { SettingRow } from "../components/profile/SettingRow.tsx";
import { UsernameField } from "../components/profile/UsernameField.tsx";
import { RENAME_LIMITS } from "../../shared/validation.ts";
// The styles' names are their own, in every language. // The styles' names are their own, in every language.
const STYLE_OPTIONS: SegmentedOption<StyleName>[] = [ const STYLE_OPTIONS: SegmentedOption<StyleName>[] = [
@@ -66,16 +59,15 @@ const ON_OFF_OPTIONS: SegmentedOption<boolean>[] = [
]; ];
/** /**
* Renders `/settings`, signed in only: the username, the password change, * Renders `/settings`, signed in only: the username, which only a moderator
* which opens {@link ChangePasswordModal}, and the settings, each saved as * changes (`ProfileManageTab`), the password change, which opens
* soon as it is clicked. A new name is saved to `PATCH /api/me`, at most once * {@link ChangePasswordModal}, and the settings, each saved as soon as it is
* in 30 days but for those with `user:manage`, and the old one keeps leading * clicked. The style, color scheme and digests are saved to the account; the
* to the user. The style, color scheme and digests are saved to the account; * default feed tab and sounds, for now, to this browser.
* the default feed tab and sounds, for now, to this browser.
*/ */
export function Settings(): React.JSX.Element { export function Settings(): React.JSX.Element {
useDocumentTitle(t`Settings`); useDocumentTitle(t`Settings`);
const { user, authFetch, updateSession } = useRequiredAuth(); const { user } = useRequiredAuth();
const { style, colorScheme, setStyle, setColorScheme } = useTheme(); const { style, colorScheme, setStyle, setColorScheme } = useTheme();
const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab(); const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab();
const [soundsEnabled, setSoundsEnabled] = useSoundsEnabled(); const [soundsEnabled, setSoundsEnabled] = useSoundsEnabled();
@@ -83,20 +75,6 @@ export function Settings(): React.JSX.Element {
// Until the session has the settings, a click would save over unknown ones. // Until the session has the settings, a click would save over unknown ones.
const digestsDisabled = !EMAIL_ENABLED || !settings; const digestsDisabled = !EMAIL_ENABLED || !settings;
const [changePasswordOpen, setChangePasswordOpen] = useState(false); const [changePasswordOpen, setChangePasswordOpen] = useState(false);
const renameDays = RENAME_LIMITS.USERNAME.windowMs / 86_400_000;
async function rename(username: string): Promise<string> {
const self = deserializeSelfUser(
await expectOk<Wire<SelfUser>>(
await authFetch(`${API_URL}/api/me`, {
method: "PATCH",
body: JSON.stringify({ username }),
}),
),
);
updateSession({ user: self });
return self.username;
}
return ( return (
<PageShell> <PageShell>
@@ -108,18 +86,7 @@ export function Settings(): React.JSX.Element {
<ProfileSection title={<Trans>Account</Trans>}> <ProfileSection title={<Trans>Account</Trans>}>
<div className="profile-appearance-grid"> <div className="profile-appearance-grid">
<SettingRow label={<Trans>Username</Trans>}> <SettingRow label={<Trans>Username</Trans>}>
<UsernameField <span className="profile-appearance-value">{user.username}</span>
key={user.username}
username={user.username}
save={rename}
hint={
<Trans>
You can change it once every {renameDays}{" "}
days. Links to your old name keep working, and nobody else can
take it.
</Trans>
}
/>
</SettingRow> </SettingRow>
<SettingRow label={<Trans>Password</Trans>}> <SettingRow label={<Trans>Password</Trans>}>
<button <button

View File

@@ -123,7 +123,11 @@ export function UserPublicProfile(): React.JSX.Element {
<Trans>← Back</Trans> <Trans>← Back</Trans>
</button> </button>
{me && ( {me && (
<button className="btn-border" type="button" onClick={logout}> <button
className="btn-border btn-border--danger"
type="button"
onClick={logout}
>
<Trans>Log out</Trans> <Trans>Log out</Trans>
</button> </button>
)} )}

View File

@@ -4,7 +4,7 @@ import { userEvent } from "@testing-library/user-event";
import { Settings } from "../../pages/Settings.tsx"; import { Settings } from "../../pages/Settings.tsx";
import { DEFAULT_SETTINGS } from "../../../shared/settings.ts"; import { DEFAULT_SETTINGS } from "../../../shared/settings.ts";
import { calls, fail, route } from "../fetch.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 { renderApp } from "../render.tsx";
import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts"; import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts";
@@ -95,45 +95,12 @@ describe("Settings", () => {
expect(weeklyOff).toHaveClass("active"); expect(weeklyOff).toHaveClass("active");
}); });
test("a new username goes to the account and the session", async () => { test("the username is shown, and can't be changed here", async () => {
route( open();
"PATCH /api/me", await screen.findByText("Username");
(req) => wire({ ...annSession.user, ...(req.body as object) }), expect(row("Username")).toHaveTextContent(annSession.user.username);
); expect(screen.queryByRole("textbox", { name: "Username" })).toBeNull();
const user = open(); expect(screen.queryByRole("button", { name: "Rename" })).toBeNull();
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("change password keeps you signed in with the fresh tokens", async () => { test("change password keeps you signed in with the fresh tokens", async () => {

View File

@@ -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( route(
"PATCH /api/users/:id/username", "PATCH /api/users/:id/username",
() => fail("Username already exists", 409), () => fail("Username already exists", 409),
@@ -515,7 +515,12 @@ describe("UserPublicProfile", () => {
url: "/@ann/manage", url: "/@ann/manage",
}); });
const name = await screen.findByRole("textbox", { name: "Username" }); 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( expect(await screen.findByRole("alert")).toHaveTextContent(
"Username already exists", "Username already exists",
); );