From f0397740d7cfb8feab237519a6ce2ac74ba2c3e8 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Mon, 13 Jul 2026 21:48:13 +0200 Subject: [PATCH] feat: let users change their auto-generated username in Settings Usernames are auto-assigned at signup (shipped earlier this session) but there was still no way to change one. Adds a Username field to Settings, validated client- and server-side against the same 3-20-char lowercase/digits/underscore pattern used for generation, with a 409 on collision (excluding the user's own current username). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 5 ++ apps/web/app/(app)/settings/page.tsx | 3 +- apps/web/app/api/v1/users/me/route.ts | 6 +++ .../web/components/settings/settings-form.tsx | 49 +++++++++++++++++++ apps/web/lib/changelog.ts | 9 +++- apps/web/lib/username.ts | 11 +++++ apps/web/messages/en.json | 4 ++ apps/web/messages/fr.json | 4 ++ apps/web/package.json | 2 +- package.json | 2 +- 10 files changed, 91 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4aaa45..abd50c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.14.0 — 2026-07-13 21:47 + +### Added +- **Change your username** in Settings — previously auto-assigned at signup with no way to change it. + ## 0.13.1 — 2026-07-13 17:25 ### Fixed diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index 411f812..ac48fe1 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -12,7 +12,7 @@ export default async function SettingsPage() { const dbUser = await db.query.users.findFirst({ where: eq(users.id, session.user.id), - columns: { bio: true, privateBio: true, isPrivate: true, hasCustomAvatar: true, avatarUrl: true }, + columns: { bio: true, privateBio: true, isPrivate: true, hasCustomAvatar: true, avatarUrl: true, username: true }, }); return ( @@ -26,6 +26,7 @@ export default async function SettingsPage() { privateBio: dbUser?.privateBio ?? null, isPrivate: dbUser?.isPrivate ?? false, hasCustomAvatar: dbUser?.hasCustomAvatar ?? false, + username: dbUser?.username ?? null, }} /> ); diff --git a/apps/web/app/api/v1/users/me/route.ts b/apps/web/app/api/v1/users/me/route.ts index ec16041..326e0e1 100644 --- a/apps/web/app/api/v1/users/me/route.ts +++ b/apps/web/app/api/v1/users/me/route.ts @@ -4,6 +4,7 @@ import { auth } from "@/lib/auth/server"; import { db, users, eq } from "@epicure/db"; import { z } from "zod"; import { gravatarUrl } from "@/lib/gravatar"; +import { USERNAME_PATTERN, isUsernameTaken } from "@/lib/username"; const PatchSchema = z.object({ name: z.string().min(1).max(100).optional(), @@ -11,6 +12,7 @@ const PatchSchema = z.object({ bio: z.string().max(500).optional().nullable(), privateBio: z.string().max(2000).optional().nullable(), isPrivate: z.boolean().optional(), + username: z.string().trim().toLowerCase().regex(USERNAME_PATTERN, "3-20 characters, lowercase letters, numbers, and underscores only").optional(), // A custom-uploaded avatar URL, or null to revert to the Gravatar fallback. avatarUrl: z.string().url().max(2048).optional().nullable(), }); @@ -22,6 +24,10 @@ export async function PATCH(req: Request) { const body = PatchSchema.safeParse(await req.json()); if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 }); + if (body.data.username !== undefined && (await isUsernameTaken(body.data.username, session.user.id))) { + return NextResponse.json({ error: "Username already taken" }, { status: 409 }); + } + const { avatarUrl, ...rest } = body.data; const updates: Partial = { ...rest }; if (avatarUrl !== undefined) { diff --git a/apps/web/components/settings/settings-form.tsx b/apps/web/components/settings/settings-form.tsx index 1c55bc1..7d5a1c9 100644 --- a/apps/web/components/settings/settings-form.tsx +++ b/apps/web/components/settings/settings-form.tsx @@ -12,6 +12,8 @@ import { useTranslations } from "next-intl"; import { useLocale, SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/provider"; import { AvatarUploader } from "./avatar-uploader"; +const USERNAME_PATTERN = /^[a-z0-9_]{3,20}$/; + type UserProps = { name: string; email: string; @@ -21,6 +23,7 @@ type UserProps = { privateBio: string | null; isPrivate: boolean; hasCustomAvatar: boolean; + username: string | null; }; export function SettingsForm({ user }: { user: UserProps }) { @@ -37,6 +40,9 @@ export function SettingsForm({ user }: { user: UserProps }) { const [savingBio, setSavingBio] = useState(false); const [isPrivate, setIsPrivate] = useState(user.isPrivate); const [savingPrivacy, setSavingPrivacy] = useState(false); + const [username, setUsername] = useState(user.username ?? ""); + const [savingUsername, setSavingUsername] = useState(false); + const [usernameError, setUsernameError] = useState(null); async function saveProfile() { setSaving(true); @@ -73,6 +79,29 @@ export function SettingsForm({ user }: { user: UserProps }) { const bioUnchanged = bio === (user.bio ?? "") && privateBio === (user.privateBio ?? ""); + async function saveUsername() { + setUsernameError(null); + if (!USERNAME_PATTERN.test(username)) { + setUsernameError(t("usernameInvalid")); + return; + } + setSavingUsername(true); + try { + const res = await fetch("/api/v1/users/me", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username }), + }); + if (res.ok) toast.success(t_common("saved")); + else if (res.status === 409) setUsernameError(t("usernameTaken")); + else toast.error(t_common("saveFailed")); + } catch { + toast.error(t_common("saveFailed")); + } finally { + setSavingUsername(false); + } + } + async function savePrivacy(checked: boolean) { setSavingPrivacy(true); const previous = isPrivate; @@ -122,6 +151,26 @@ export function SettingsForm({ user }: { user: UserProps }) { +
+

{t("username")}

+

{t("usernameDescription")}

+
+
+ @ + { setUsername(e.target.value.toLowerCase()); setUsernameError(null); }} + maxLength={20} + className="max-w-xs" + /> +
+ {usernameError &&

{usernameError}

} +
+ +
+

{t("bio")}

diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index d8b2f86..00d17f3 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // Mirrors CHANGELOG.md at the repo root — update both together. -export const APP_VERSION = "0.13.1"; +export const APP_VERSION = "0.14.0"; export type ChangelogEntry = { version: string; @@ -11,6 +11,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.14.0", + date: "2026-07-13 21:47", + added: [ + "**Change your username** in Settings — previously auto-assigned at signup with no way to change it.", + ], + }, { version: "0.13.1", date: "2026-07-13 17:25", diff --git a/apps/web/lib/username.ts b/apps/web/lib/username.ts index c1d7316..0093b7a 100644 --- a/apps/web/lib/username.ts +++ b/apps/web/lib/username.ts @@ -1,5 +1,7 @@ import { db, users, eq } from "@epicure/db"; +export const USERNAME_PATTERN = /^[a-z0-9_]{3,20}$/; + /** Lowercase alnum-and-underscore slug, at least 3 chars, at most 20. */ function slugify(seed: string): string { const base = seed @@ -31,3 +33,12 @@ export async function generateUniqueUsername(seed: string): Promise { candidate = `${base}${suffix}`.slice(0, 20); } } + +/** True if `candidate` belongs to some other user — a user re-saving their own current username is not a conflict. */ +export async function isUsernameTaken(candidate: string, excludeUserId: string): Promise { + const existing = await db.query.users.findFirst({ + where: eq(users.username, candidate), + columns: { id: true }, + }); + return !!existing && existing.id !== excludeUserId; +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 99b81cd..378c17a 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1133,6 +1133,10 @@ "avatarUploadFailed": "Failed to update profile photo", "avatarRemoved": "Profile photo removed", "displayName": "Display name", + "username": "Username", + "usernameDescription": "Your profile URL and how people find you when following or mentioning you.", + "usernameInvalid": "3-20 characters: lowercase letters, numbers, and underscores only.", + "usernameTaken": "That username is already taken.", "email": "Email", "emailReadOnly": "Email cannot be changed here.", "saving": "Saving…", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index fe07599..fb93ac6 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -1121,6 +1121,10 @@ "avatarUploadFailed": "Échec de la mise à jour de la photo de profil", "avatarRemoved": "Photo de profil supprimée", "displayName": "Nom d'affichage", + "username": "Nom d'utilisateur", + "usernameDescription": "L'URL de votre profil et la façon dont les autres vous trouvent pour vous suivre ou vous mentionner.", + "usernameInvalid": "3 à 20 caractères : lettres minuscules, chiffres et underscores uniquement.", + "usernameTaken": "Ce nom d'utilisateur est déjà pris.", "email": "E-mail", "emailReadOnly": "L'e-mail ne peut pas être modifié ici.", "saving": "Enregistrement…", diff --git a/apps/web/package.json b/apps/web/package.json index 6a0f7b9..cb2299f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.13.1", + "version": "0.14.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 02758e6..c43df06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.13.1", + "version": "0.14.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",