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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.13.1",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.13.1",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
|
||||
Reference in New Issue
Block a user