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:
8
API.md
8
API.md
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
45
src/App.css
45
src/App.css
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user