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.
- **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.
A user may rename themselves once every 30 days from `/settings`; those
with `user:manage` rename anyone, at any time, from `/@<name>/manage`.
Users can't rename themselves: those with `user:manage` rename anyone, from
`/@<name>/manage`.
- **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
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
- `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`, `PATCH /api/me/settings` — signed in. The caller's settings.
- `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.
- `GET /api/users/:key/invitees` — anyone. The tree of users they invited.
- `PATCH /api/users/:key/role`, `/username` — `user:manage`. Another user's
role, or anyone's name.
role, or anyone's name, theirs included.
### dumps

View File

@@ -6,9 +6,9 @@
* `routes/avatars.ts`.
*
* - `GET /api/me` — signed in. Answers the caller's own user.
* - `PATCH /api/me` — signed in. Changes the caller's description, email and
* username, and answers their user. A new username keeps the old one as an
* alias, at most once in 30 days but for those with `user:manage`.
* - `PATCH /api/me` — signed in. Changes the caller's description and email,
* and answers their user. A username changes only by `user:manage`, through
* `PATCH /api/users/:key/username`.
* - `GET /api/me/media-token` — signed in. Answers a media token, for a
* session that predates them.
* - `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 { createMediaToken, issueTokens } from "../lib/jwt.ts";
import { verifyPassword } from "../lib/password.ts";
import { can } from "../lib/permissions.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
import {
getUserById,
renameUser,
updateUser,
} from "../services/user-service.ts";
import { getUserById, updateUser } from "../services/user-service.ts";
import { getSettings, updateSettings } from "../services/settings-service.ts";
import { getPlaylistMembershipsForDump } from "../services/playlist-service.ts";
import {
broadcastUserUpdated,
updateClientUsername,
} from "../services/ws-service.ts";
import { broadcastUserUpdated } from "../services/ws-service.ts";
import { VALIDATION } from "../../shared/validation.ts";
/**
@@ -74,16 +66,7 @@ router.patch("/", authMiddleware, async (ctx: AuthContext) => {
}
const userId = ctx.state.user.userId;
// 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
// 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);
}
// password, which needs the current one.
const updated = await updateUser(userId, {
description: body.description,
email: body.email,

View File

@@ -31,7 +31,7 @@
* - `PATCH /api/users/:key/role` — permission `user:manage`. Sets another
* user's role.
* - `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
*/
@@ -293,7 +293,7 @@ router.patch(
throw new APIException(400, "Invalid request");
}
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);
broadcastUserUpdated(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 { isEmailEnabled, sendEmail } from "./email-service.ts";
import { renderWelcomeEmail } from "../mail/welcome.ts";
import {
RENAME_LIMITS,
USERNAME_RE,
VALIDATION,
} from "../../shared/validation.ts";
import { USERNAME_RE, VALIDATION } from "../../shared/validation.ts";
/** Selects full `UserRow`s from `users u`, with the inviter's name joined. */
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
* moderator, `PATCH /api/users/:key/username`, and returns them renamed. The
* old name becomes an alias of theirs for good, so `/@old/…` still reaches
* them and nobody else may take it; a change of case alone leaves none, since
* names are looked up in any case. Taking back a former name of one's own is
* allowed. Their dumps' and playlists' slugs belong to their id, so their
* paths follow the new name. Unless `exempt` (a moderator), a user may change
* names once in `RENAME_LIMITS.USERNAME`'s window.
* Renames user `userId` to `username`, for `PATCH /api/users/:key/username`,
* which only `user:manage` may call: users can't rename themselves. Returns
* them renamed. The old name becomes an alias of theirs for good, so
* `/@old/…` still reaches them and nobody else may take it; a change of case
* alone leaves none, since names are looked up in any case. Taking back a
* former name of their own is allowed. Their dumps' and playlists' slugs
* belong to their id, so their paths follow the new name.
*
* @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
* the user changed names too recently.
* there is no such user, 409 when the name is or was someone else's.
*/
export function renameUser(
userId: string,
username: string,
exempt: boolean,
): User {
export function renameUser(userId: string, username: string): User {
if (!USERNAME_RE.test(username)) {
throw new APIException(
400,
@@ -233,33 +223,19 @@ export function renameUser(
const user = getUserById(userId);
if (username === user.username) return user;
const now = new Date();
const now = new Date().toISOString();
transaction(() => {
assertUsernameAvailable(username, userId);
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(
`DELETE FROM user_aliases WHERE user_id = ? AND lower(username) = lower(?);`,
).run(userId, username);
db.prepare(
`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 = ?;`)
.run(username, now.toISOString(), userId);
.run(username, now, 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
* `request`: the routes pick the fields a caller may change. The username
* changes through {@link renameUser} alone, which keeps the old one as an
* alias, so a `username` here is ignored. A new password
* also ends every session opened before it and closes the user's sockets, so
* the caller must issue fresh tokens if the user is to stay signed in. A
* `description` of null keeps the current one.
* alias. A new password also ends every session opened before it and closes
* the user's sockets, so the caller must issue fresh tokens if the user is to
* stay signed in. A `description` of null keeps the current one.
*
* @throws {APIException} 404 when there is no such user, 409 when the new
* email belongs to another account.
@@ -356,8 +331,7 @@ export async function updateUser(
): Promise<User> {
const user = getUserById(userId);
const { password, description, email, username: _, ...requestFields } =
request;
const { password, description, email, ...requestFields } = request;
if (email !== undefined && email !== user.email) {
assertEmailAvailable(email, userId);
}

View File

@@ -23,7 +23,12 @@ import type {
SelfUser,
UserSuggestion,
} from "../../../shared/user.ts";
import { assert, assertEquals, assertFalse } from "@std/assert";
import {
assert,
assertEquals,
assertFalse,
assertNotEquals,
} from "@std/assert";
import {
createPasswordResetToken,
verifyJWT,
@@ -69,7 +74,7 @@ test("public profile: by @name in any case or id, without private fields", async
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 taken = await createUser();
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 ──────────────────────────────────────────────────────
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 old = bob.username;
const renamed = uniqueName("robert");
const dump = insertDump({ userId: bob.id, title: "Never gonna" });
const playlist = insertPlaylist({ userId: bob.id, title: "Sunday mix" });
const rename = (key: string, username: string) =>
patch<PublicUser>(`/api/users/${key}/username`, {
auth: admin.auth,
json: { username },
});
const res = await patch<SelfUser>("/api/me", {
auth: bob.auth,
json: { username: renamed },
});
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.
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]);
}
// 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 register = await post("/api/users", {
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(
(await patch("/api/me", { auth: other.auth, json: { username: old } }))
.status,
409,
);
assertEquals((await rename(other.id, old)).status, 409);
// Nor to a current one.
assertEquals(
(await patch("/api/me", {
auth: other.auth,
json: { username: renamed },
})).status,
409,
);
assertEquals((await rename(other.id, renamed)).status, 409);
// Signing in takes the current name only.
assertEquals(
(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 first = uniqueName("first");
assertEquals(
(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,
json: { username: first.toUpperCase() },
})).data.username,
first.toUpperCase(),
);
const again = await patch("/api/me", {
// PATCH /api/me refuses a name, and changes nothing else with it.
const self = await patch("/api/me", {
auth: user.auth,
json: { username: uniqueName("second") },
json: { username: uniqueName("self"), description: "hi" },
});
assertEquals(again.status, 429);
assertEquals(
(await patch("/api/me", { auth: user.auth, json: { username: "a b" } }))
.status,
400,
);
assertEquals(self.status, 400);
const me = await get<SelfUser>("/api/me", { auth: user.auth });
assertEquals(me.data.username, user.username);
assertNotEquals(me.data.description, "hi");
// 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 third = uniqueName("third");
assertEquals(
(await patch<PublicUser>(`/api/users/${user.id}/username`, {
const rename = (key: string, username: string) =>
patch<PublicUser>(`/api/users/${key}/username`, {
auth: admin.auth,
json: { username: third },
})).data.username,
third,
json: { username },
});
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(
(await patch<PublicUser>(`/api/users/@${third}/username`, {
auth: admin.auth,
json: { username: first },
})).data.username,
(await rename(`@${first}`, first)).data.username,
first,
"a former name of one's own can be taken back",
);
for (let i = 0; i < 2; i++) {
assertEquals(
(await patch("/api/me", {
auth: admin.auth,
json: { username: uniqueName("admin") },
})).status,
200,
);
}
assertEquals((await rename(user.id, "a b")).status, 400);
const adminName = uniqueName("admin");
assertEquals((await rename(admin.id, adminName)).data.username, adminName);
// Others may not.
const moderator = await createUser({ role: "moderator" });
assertEquals(

View File

@@ -44,7 +44,8 @@ Deno.test("isUpdateUserRequest", () => {
assert(isUpdateUserRequest({ description: "hi", email: "a@b.co" }));
assertFalse(isUpdateUserRequest({ description: "x".repeat(2001) }));
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({ role: "root" }));
assertFalse(isUpdateUserRequest("string"));

View File

@@ -81,10 +81,9 @@ export function validateRegisterUserRequest(obj: unknown): string | null {
export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
if ("username" in o) {
if (typeof o.username !== "string") return false;
if (!USERNAME_RE.test(o.username as string)) return false;
}
// A user can't rename themselves: a moderator does, through
// `PATCH /api/users/:key/username`.
if ("username" in o) return false;
if ("password" in o) {
if (typeof o.password !== "string") return false;
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
#: api/mail/digest.ts:221
#: src/pages/Settings.tsx:190
#: src/pages/Settings.tsx:157
msgid "digest.monthly"
msgstr "Monthly recap"
#. js-lingui-explicit-id
#: api/mail/digest.ts:221
#: src/pages/Settings.tsx:181
#: src/pages/Settings.tsx:148
msgid "digest.weekly"
msgstr "Weekly digest"
@@ -501,7 +501,7 @@ msgstr "A link is required"
msgid "a post"
msgstr "a post"
#: src/pages/Settings.tsx:108
#: src/pages/Settings.tsx:86
msgid "Account"
msgstr "Account"
@@ -550,7 +550,7 @@ msgstr "Already have an account? <0>Log in</0>"
msgid "An unexpected error occurred."
msgstr "An unexpected error occurred."
#: src/pages/Settings.tsx:135
#: src/pages/Settings.tsx:102
msgid "Appearance"
msgstr "Appearance"
@@ -565,7 +565,7 @@ msgstr "At least {0} characters"
msgid "At most {maxLength} characters."
msgstr "At most {maxLength} characters."
#: src/pages/Settings.tsx:51
#: src/pages/Settings.tsx:44
msgid "Auto"
msgstr "Auto"
@@ -629,7 +629,7 @@ msgstr "Change image"
msgid "Change password"
msgstr "Change password"
#: src/pages/Settings.tsx:130
#: src/pages/Settings.tsx:97
msgid "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"
msgstr "Collapse player"
#: src/pages/Settings.tsx:144
#: src/pages/Settings.tsx:111
msgid "Color scheme"
msgstr "Color scheme"
@@ -752,11 +752,11 @@ msgstr "Creating…"
msgid "Current password"
msgstr "Current password"
#: src/pages/Settings.tsx:53
#: src/pages/Settings.tsx:46
msgid "Dark"
msgstr "Dark"
#: src/pages/Settings.tsx:155
#: src/pages/Settings.tsx:122
msgid "Default tab"
msgstr "Default tab"
@@ -944,7 +944,7 @@ msgstr "Editing"
msgid "Editing {0}"
msgstr "Editing {0}"
#: src/pages/Settings.tsx:179
#: src/pages/Settings.tsx:146
msgid "Email"
msgstr "Email"
@@ -1017,7 +1017,7 @@ msgstr "Failed to update avatar"
msgid "Failed to update role"
msgstr "Failed to update role"
#: src/pages/Settings.tsx:153
#: src/pages/Settings.tsx:120
msgid "Feeds"
msgstr "Feeds"
@@ -1052,7 +1052,7 @@ msgstr "Follow some users to see their dumps here."
#: src/components/feed/FeedTabBar.tsx:43
#: src/components/profile/ProfileFollowTab.tsx:163
#: src/pages/Settings.tsx:60
#: src/pages/Settings.tsx:53
#: src/pages/UserPublicProfile.tsx:157
msgid "Followed"
msgstr "Followed"
@@ -1087,7 +1087,7 @@ msgid "Go to login"
msgstr "Go to login"
#: src/components/feed/FeedTabBar.tsx:39
#: src/pages/Settings.tsx:57
#: src/pages/Settings.tsx:50
msgid "Hot"
msgstr "Hot"
@@ -1132,7 +1132,7 @@ msgid "Invitees"
msgstr "Invitees"
#: src/components/feed/FeedTabBar.tsx:41
#: src/pages/Settings.tsx:59
#: src/pages/Settings.tsx:52
msgid "Journal"
msgstr "Journal"
@@ -1144,7 +1144,7 @@ msgstr "Keep editing"
msgid "Letters, numbers and underscores only"
msgstr "Letters, numbers and underscores only"
#: src/pages/Settings.tsx:52
#: src/pages/Settings.tsx:45
msgid "Light"
msgstr "Light"
@@ -1276,7 +1276,7 @@ msgstr "new"
#: src/components/chat/ChatModal.tsx:669
#: src/components/feed/FeedTabBar.tsx:40
#: src/pages/Settings.tsx:58
#: src/pages/Settings.tsx:51
msgid "New"
msgstr "New"
@@ -1368,7 +1368,7 @@ msgstr "Nothing here yet."
#: src/components/layout/NotificationBell.tsx:54
#: src/pages/Notifications.tsx:308
#: src/pages/Notifications.tsx:364
#: src/pages/Settings.tsx:164
#: src/pages/Settings.tsx:131
msgid "Notifications"
msgstr "Notifications"
@@ -1376,11 +1376,11 @@ msgstr "Notifications"
msgid "Notifications ({unreadNotificationCount} unread)"
msgstr "Notifications ({unreadNotificationCount} unread)"
#: src/pages/Settings.tsx:65
#: src/pages/Settings.tsx:58
msgid "Off"
msgstr "Off"
#: src/pages/Settings.tsx:64
#: src/pages/Settings.tsx:57
msgid "On"
msgstr "On"
@@ -1413,7 +1413,7 @@ msgstr "Overview"
msgid "Page not found"
msgstr "Page not found"
#: src/pages/Settings.tsx:124
#: src/pages/Settings.tsx:91
#: src/pages/UserLogin.tsx:82
msgid "Password"
msgstr "Password"
@@ -1690,8 +1690,8 @@ msgstr "Set new password"
#: src/components/layout/UserMenu.tsx:89
#: src/components/profile/ProfileHeader.tsx:177
#: src/pages/Settings.tsx:77
#: src/pages/Settings.tsx:105
#: src/pages/Settings.tsx:69
#: src/pages/Settings.tsx:83
msgid "Settings"
msgstr "Settings"
@@ -1718,7 +1718,7 @@ msgstr "Some images couldn't be uploaded."
msgid "Something went wrong"
msgstr "Something went wrong"
#: src/pages/Settings.tsx:166
#: src/pages/Settings.tsx:133
msgid "Sounds"
msgstr "Sounds"
@@ -1726,7 +1726,7 @@ msgstr "Sounds"
msgid "Start fresh"
msgstr "Start fresh"
#: src/pages/Settings.tsx:137
#: src/pages/Settings.tsx:104
msgid "Style"
msgstr "Style"
@@ -1778,7 +1778,7 @@ msgstr "This page does not exist."
msgid "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."
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/UsernameField.tsx:55
#: src/pages/Settings.tsx:110
#: src/pages/Settings.tsx:88
#: src/pages/UserLogin.tsx:75
#: src/pages/UserRegister.tsx:137
msgid "Username"
@@ -1970,10 +1970,6 @@ msgstr "Write a reply…"
msgid "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
msgid "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
#: api/mail/digest.ts:221
#: src/pages/Settings.tsx:190
#: src/pages/Settings.tsx:157
msgid "digest.monthly"
msgstr "Récap mensuel"
#. js-lingui-explicit-id
#: api/mail/digest.ts:221
#: src/pages/Settings.tsx:181
#: src/pages/Settings.tsx:148
msgid "digest.weekly"
msgstr "Résumé hebdomadaire"
@@ -501,7 +501,7 @@ msgstr "Il faut un lien"
msgid "a post"
msgstr "une publication"
#: src/pages/Settings.tsx:108
#: src/pages/Settings.tsx:86
msgid "Account"
msgstr "Compte"
@@ -550,7 +550,7 @@ msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
msgid "An unexpected error occurred."
msgstr "Une erreur inattendue s'est produite."
#: src/pages/Settings.tsx:135
#: src/pages/Settings.tsx:102
msgid "Appearance"
msgstr "Apparence"
@@ -565,7 +565,7 @@ msgstr "Au moins {0} caractères"
msgid "At most {maxLength} characters."
msgstr "{maxLength} caractères au maximum."
#: src/pages/Settings.tsx:51
#: src/pages/Settings.tsx:44
msgid "Auto"
msgstr "Auto"
@@ -629,7 +629,7 @@ msgstr "Changer l'image"
msgid "Change password"
msgstr "Changer le mot de passe"
#: src/pages/Settings.tsx:130
#: src/pages/Settings.tsx:97
msgid "Change password…"
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"
msgstr "Réduire le lecteur"
#: src/pages/Settings.tsx:144
#: src/pages/Settings.tsx:111
msgid "Color scheme"
msgstr "Thème de couleur"
@@ -752,11 +752,11 @@ msgstr "Création…"
msgid "Current password"
msgstr "Mot de passe actuel"
#: src/pages/Settings.tsx:53
#: src/pages/Settings.tsx:46
msgid "Dark"
msgstr "Sombre"
#: src/pages/Settings.tsx:155
#: src/pages/Settings.tsx:122
msgid "Default tab"
msgstr "Onglet par défaut"
@@ -944,7 +944,7 @@ msgstr "Modification"
msgid "Editing {0}"
msgstr "Édition de {0}"
#: src/pages/Settings.tsx:179
#: src/pages/Settings.tsx:146
msgid "Email"
msgstr "E-mail"
@@ -1017,7 +1017,7 @@ msgstr "Impossible de mettre à jour l'avatar"
msgid "Failed to update role"
msgstr "Erreur lors de la mise à jour du rôle"
#: src/pages/Settings.tsx:153
#: src/pages/Settings.tsx:120
msgid "Feeds"
msgstr "Flux"
@@ -1052,7 +1052,7 @@ msgstr "Suivez des utilisateurs pour voir leurs recos ici."
#: src/components/feed/FeedTabBar.tsx:43
#: src/components/profile/ProfileFollowTab.tsx:163
#: src/pages/Settings.tsx:60
#: src/pages/Settings.tsx:53
#: src/pages/UserPublicProfile.tsx:157
msgid "Followed"
msgstr "Suivi"
@@ -1087,7 +1087,7 @@ msgid "Go to login"
msgstr "Aller à la connexion"
#: src/components/feed/FeedTabBar.tsx:39
#: src/pages/Settings.tsx:57
#: src/pages/Settings.tsx:50
msgid "Hot"
msgstr "Tendances"
@@ -1132,7 +1132,7 @@ msgid "Invitees"
msgstr "Invités"
#: src/components/feed/FeedTabBar.tsx:41
#: src/pages/Settings.tsx:59
#: src/pages/Settings.tsx:52
msgid "Journal"
msgstr "Journal"
@@ -1144,7 +1144,7 @@ msgstr "Continuer l'édition"
msgid "Letters, numbers and underscores only"
msgstr "Lettres, chiffres et tirets bas seulement"
#: src/pages/Settings.tsx:52
#: src/pages/Settings.tsx:45
msgid "Light"
msgstr "Clair"
@@ -1276,7 +1276,7 @@ msgstr "nouveau"
#: src/components/chat/ChatModal.tsx:669
#: src/components/feed/FeedTabBar.tsx:40
#: src/pages/Settings.tsx:58
#: src/pages/Settings.tsx:51
msgid "New"
msgstr "Nouveau"
@@ -1368,7 +1368,7 @@ msgstr "Rien ici pour l'instant."
#: src/components/layout/NotificationBell.tsx:54
#: src/pages/Notifications.tsx:308
#: src/pages/Notifications.tsx:364
#: src/pages/Settings.tsx:164
#: src/pages/Settings.tsx:131
msgid "Notifications"
msgstr "Notifications"
@@ -1376,11 +1376,11 @@ msgstr "Notifications"
msgid "Notifications ({unreadNotificationCount} unread)"
msgstr "Notifications ({unreadNotificationCount} non lues)"
#: src/pages/Settings.tsx:65
#: src/pages/Settings.tsx:58
msgid "Off"
msgstr "Désactivés"
#: src/pages/Settings.tsx:64
#: src/pages/Settings.tsx:57
msgid "On"
msgstr "Activés"
@@ -1413,7 +1413,7 @@ msgstr "Aperçu"
msgid "Page not found"
msgstr "Page introuvable"
#: src/pages/Settings.tsx:124
#: src/pages/Settings.tsx:91
#: src/pages/UserLogin.tsx:82
msgid "Password"
msgstr "Mot de passe"
@@ -1690,8 +1690,8 @@ msgstr "Définir un nouveau mot de passe"
#: src/components/layout/UserMenu.tsx:89
#: src/components/profile/ProfileHeader.tsx:177
#: src/pages/Settings.tsx:77
#: src/pages/Settings.tsx:105
#: src/pages/Settings.tsx:69
#: src/pages/Settings.tsx:83
msgid "Settings"
msgstr "Paramètres"
@@ -1718,7 +1718,7 @@ msgstr "Certaines images n'ont pas pu être envoyées."
msgid "Something went wrong"
msgstr "Une erreur est survenue"
#: src/pages/Settings.tsx:166
#: src/pages/Settings.tsx:133
msgid "Sounds"
msgstr "Sons"
@@ -1726,7 +1726,7 @@ msgstr "Sons"
msgid "Start fresh"
msgstr "Repartir de zéro"
#: src/pages/Settings.tsx:137
#: src/pages/Settings.tsx:104
msgid "Style"
msgstr "Style"
@@ -1778,7 +1778,7 @@ msgstr "Rien à voir, circulez."
msgid "This reset link is missing or malformed."
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."
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/UsernameField.tsx:55
#: src/pages/Settings.tsx:110
#: src/pages/Settings.tsx:88
#: src/pages/UserLogin.tsx:75
#: src/pages/UserRegister.tsx:137
msgid "Username"
@@ -1970,10 +1970,6 @@ msgstr "Écrire une réponse…"
msgid "Yesterday"
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
msgid "You can post it anyway."
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
* by a client to `PATCH /api/me`, which takes only `description`, `email` and
* `username`; the role and the password change through their own endpoints,
* and the username through `renameUser`.
* by a client to `PATCH /api/me`, which takes only `description` and `email`;
* the role and the password change through their own endpoints, and the
* username through `renameUser`, by a moderator only.
*/
export interface UpdateUserRequest {
username?: string;
password?: string;
role?: Role;
/** 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
* changes left: a dump's or a playlist's slug 3 times a day, a username once
* a month. Moderators are exempt. The services refuse a change past it with a
* 429, and the forms say what the limit is.
* How often a dump's or a playlist's slug may change, counted from the aliases
* the changes left: 3 times a day. Moderators are exempt. slug-service refuses
* a change past it with a 429, and the edit forms say what the limit is.
*/
export const RENAME_LIMITS = {
SLUG: { count: 3, windowMs: 24 * 3_600_000 },
USERNAME: { count: 1, windowMs: 30 * 24 * 3_600_000 },
} as const;
/**

View File

@@ -793,8 +793,11 @@
}
/* ── Mode toggle — segmented control ── */
/* Too wide for its container, it wraps onto more rows, each filled. */
.visibility-toggle {
display: flex;
flex-wrap: wrap;
max-width: 100%;
background: var(--color-bg);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-lg);
@@ -816,6 +819,14 @@
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 {
background: var(--color-accent);
color: var(--color-on-accent);
@@ -2646,17 +2657,32 @@ body.has-player .chat-fab {
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-muted);
color: var(--color-text);
font-size: var(--text-sm);
/* A <button> and a <Link> come out the same height. */
line-height: 1.5;
font-family: inherit;
cursor: pointer;
transition: border-color var(--dur-base), color var(--dur-base), background
var(--dur-base);
}
/* Neutral: the accent can be a red (Smooth's), which would read as danger. */
.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);
color: var(--color-danger);
background: transparent;
}
/* ── ErrorCard ── */
@@ -7045,22 +7071,27 @@ body.has-player .chat-fab {
}
/* ── 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 {
display: grid;
grid-template-columns: max-content max-content;
grid-template-columns: max-content minmax(0, 1fr);
gap: 0.75rem 1rem;
align-items: center;
justify-items: start;
}
.profile-appearance-row {
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) {
.profile-appearance-grid {
display: flex;
flex-direction: column;
align-items: flex-start;
align-items: stretch;
gap: 1rem;
}
@@ -7070,6 +7101,14 @@ body.has-player .chat-fab {
align-items: flex-start;
gap: 0.3rem;
}
.profile-appearance-row > .visibility-toggle {
align-self: stretch;
}
}
.profile-appearance-value {
font-weight: 600;
}
.profile-appearance-label {

View File

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

View File

@@ -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<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;
}

View File

@@ -29,12 +29,28 @@ export interface SegmentedProps<V> {
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<V>(
{ options, value, onChange, disabled, className }: SegmentedProps<V>,
): React.JSX.Element {
const many = options.length > MAX_ONE_ROW;
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) => (
<button
key={String(opt.value)}

View File

@@ -1,6 +1,6 @@
/**
* 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
*/
@@ -13,9 +13,6 @@ import { Trans } from "@lingui/react/macro";
import { Trans as Message } from "@lingui/react";
import type { ColorScheme, StyleName } from "../../shared/theme.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 { useDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.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 { EMAIL_ENABLED } from "../config/instance.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 {
Segmented,
@@ -34,8 +29,6 @@ import {
import { ChangePasswordModal } from "../components/profile/ChangePasswordModal.tsx";
import { ProfileSection } from "../components/profile/ProfileSection.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.
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,
* which opens {@link ChangePasswordModal}, and the settings, each saved as
* soon as it is clicked. A new name is saved to `PATCH /api/me`, at most once
* in 30 days but for those with `user:manage`, and the old one keeps leading
* to the user. The style, color scheme and digests are saved to the account;
* the default feed tab and sounds, for now, to this browser.
* Renders `/settings`, signed in only: the username, which only a moderator
* changes (`ProfileManageTab`), the password change, which opens
* {@link ChangePasswordModal}, and the settings, each saved as soon as it is
* clicked. The style, color scheme and digests are saved to the account; the
* default feed tab and sounds, for now, to this browser.
*/
export function Settings(): React.JSX.Element {
useDocumentTitle(t`Settings`);
const { user, authFetch, updateSession } = useRequiredAuth();
const { user } = useRequiredAuth();
const { style, colorScheme, setStyle, setColorScheme } = useTheme();
const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab();
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.
const digestsDisabled = !EMAIL_ENABLED || !settings;
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 (
<PageShell>
@@ -108,18 +86,7 @@ export function Settings(): React.JSX.Element {
<ProfileSection title={<Trans>Account</Trans>}>
<div className="profile-appearance-grid">
<SettingRow label={<Trans>Username</Trans>}>
<UsernameField
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>
}
/>
<span className="profile-appearance-value">{user.username}</span>
</SettingRow>
<SettingRow label={<Trans>Password</Trans>}>
<button

View File

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

View File

@@ -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 () => {

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(
"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",
);