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 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-13 21:48:13 +02:00
parent 2ee99ea463
commit f0397740d7
10 changed files with 91 additions and 4 deletions
+5
View File
@@ -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
+2 -1
View File
@@ -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,
}}
/>
);
+6
View File
@@ -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<typeof users.$inferInsert> = { ...rest };
if (avatarUrl !== undefined) {
@@ -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<string | null>(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 }) {
</Button>
</section>
<section className="rounded-xl border p-6 space-y-4">
<h2 className="font-semibold text-lg">{t("username")}</h2>
<p className="text-xs text-muted-foreground">{t("usernameDescription")}</p>
<div className="space-y-2">
<div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">@</span>
<Input
value={username}
onChange={(e) => { setUsername(e.target.value.toLowerCase()); setUsernameError(null); }}
maxLength={20}
className="max-w-xs"
/>
</div>
{usernameError && <p className="text-xs text-destructive">{usernameError}</p>}
</div>
<Button onClick={saveUsername} disabled={savingUsername || username === (user.username ?? "")}>
{savingUsername ? t("saving") : t_common("save")}
</Button>
</section>
<section className="rounded-xl border p-6 space-y-4">
<h2 className="font-semibold text-lg">{t("bio")}</h2>
<div className="space-y-2">
+8 -1
View File
@@ -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",
+11
View File
@@ -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<string> {
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<boolean> {
const existing = await db.query.users.findFirst({
where: eq(users.username, candidate),
columns: { id: true },
});
return !!existing && existing.id !== excludeUserId;
}
+4
View File
@@ -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…",
+4
View File
@@ -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…",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.13.1",
"version": "0.14.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.13.1",
"version": "0.14.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",