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.
|
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
|
## 0.13.1 — 2026-07-13 17:25
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default async function SettingsPage() {
|
|||||||
|
|
||||||
const dbUser = await db.query.users.findFirst({
|
const dbUser = await db.query.users.findFirst({
|
||||||
where: eq(users.id, session.user.id),
|
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 (
|
return (
|
||||||
@@ -26,6 +26,7 @@ export default async function SettingsPage() {
|
|||||||
privateBio: dbUser?.privateBio ?? null,
|
privateBio: dbUser?.privateBio ?? null,
|
||||||
isPrivate: dbUser?.isPrivate ?? false,
|
isPrivate: dbUser?.isPrivate ?? false,
|
||||||
hasCustomAvatar: dbUser?.hasCustomAvatar ?? 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 { db, users, eq } from "@epicure/db";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { gravatarUrl } from "@/lib/gravatar";
|
import { gravatarUrl } from "@/lib/gravatar";
|
||||||
|
import { USERNAME_PATTERN, isUsernameTaken } from "@/lib/username";
|
||||||
|
|
||||||
const PatchSchema = z.object({
|
const PatchSchema = z.object({
|
||||||
name: z.string().min(1).max(100).optional(),
|
name: z.string().min(1).max(100).optional(),
|
||||||
@@ -11,6 +12,7 @@ const PatchSchema = z.object({
|
|||||||
bio: z.string().max(500).optional().nullable(),
|
bio: z.string().max(500).optional().nullable(),
|
||||||
privateBio: z.string().max(2000).optional().nullable(),
|
privateBio: z.string().max(2000).optional().nullable(),
|
||||||
isPrivate: z.boolean().optional(),
|
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.
|
// A custom-uploaded avatar URL, or null to revert to the Gravatar fallback.
|
||||||
avatarUrl: z.string().url().max(2048).optional().nullable(),
|
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());
|
const body = PatchSchema.safeParse(await req.json());
|
||||||
if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 });
|
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 { avatarUrl, ...rest } = body.data;
|
||||||
const updates: Partial<typeof users.$inferInsert> = { ...rest };
|
const updates: Partial<typeof users.$inferInsert> = { ...rest };
|
||||||
if (avatarUrl !== undefined) {
|
if (avatarUrl !== undefined) {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useLocale, SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/provider";
|
import { useLocale, SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/provider";
|
||||||
import { AvatarUploader } from "./avatar-uploader";
|
import { AvatarUploader } from "./avatar-uploader";
|
||||||
|
|
||||||
|
const USERNAME_PATTERN = /^[a-z0-9_]{3,20}$/;
|
||||||
|
|
||||||
type UserProps = {
|
type UserProps = {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -21,6 +23,7 @@ type UserProps = {
|
|||||||
privateBio: string | null;
|
privateBio: string | null;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
hasCustomAvatar: boolean;
|
hasCustomAvatar: boolean;
|
||||||
|
username: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SettingsForm({ user }: { user: UserProps }) {
|
export function SettingsForm({ user }: { user: UserProps }) {
|
||||||
@@ -37,6 +40,9 @@ export function SettingsForm({ user }: { user: UserProps }) {
|
|||||||
const [savingBio, setSavingBio] = useState(false);
|
const [savingBio, setSavingBio] = useState(false);
|
||||||
const [isPrivate, setIsPrivate] = useState(user.isPrivate);
|
const [isPrivate, setIsPrivate] = useState(user.isPrivate);
|
||||||
const [savingPrivacy, setSavingPrivacy] = useState(false);
|
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() {
|
async function saveProfile() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -73,6 +79,29 @@ export function SettingsForm({ user }: { user: UserProps }) {
|
|||||||
|
|
||||||
const bioUnchanged = bio === (user.bio ?? "") && privateBio === (user.privateBio ?? "");
|
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) {
|
async function savePrivacy(checked: boolean) {
|
||||||
setSavingPrivacy(true);
|
setSavingPrivacy(true);
|
||||||
const previous = isPrivate;
|
const previous = isPrivate;
|
||||||
@@ -122,6 +151,26 @@ export function SettingsForm({ user }: { user: UserProps }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</section>
|
</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">
|
<section className="rounded-xl border p-6 space-y-4">
|
||||||
<h2 className="font-semibold text-lg">{t("bio")}</h2>
|
<h2 className="font-semibold text-lg">{t("bio")}</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// 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 = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.13.1",
|
||||||
date: "2026-07-13 17:25",
|
date: "2026-07-13 17:25",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { db, users, eq } from "@epicure/db";
|
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. */
|
/** Lowercase alnum-and-underscore slug, at least 3 chars, at most 20. */
|
||||||
function slugify(seed: string): string {
|
function slugify(seed: string): string {
|
||||||
const base = seed
|
const base = seed
|
||||||
@@ -31,3 +33,12 @@ export async function generateUniqueUsername(seed: string): Promise<string> {
|
|||||||
candidate = `${base}${suffix}`.slice(0, 20);
|
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",
|
"avatarUploadFailed": "Failed to update profile photo",
|
||||||
"avatarRemoved": "Profile photo removed",
|
"avatarRemoved": "Profile photo removed",
|
||||||
"displayName": "Display name",
|
"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",
|
"email": "Email",
|
||||||
"emailReadOnly": "Email cannot be changed here.",
|
"emailReadOnly": "Email cannot be changed here.",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
|
|||||||
@@ -1121,6 +1121,10 @@
|
|||||||
"avatarUploadFailed": "Échec de la mise à jour de la photo de profil",
|
"avatarUploadFailed": "Échec de la mise à jour de la photo de profil",
|
||||||
"avatarRemoved": "Photo de profil supprimée",
|
"avatarRemoved": "Photo de profil supprimée",
|
||||||
"displayName": "Nom d'affichage",
|
"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",
|
"email": "E-mail",
|
||||||
"emailReadOnly": "L'e-mail ne peut pas être modifié ici.",
|
"emailReadOnly": "L'e-mail ne peut pas être modifié ici.",
|
||||||
"saving": "Enregistrement…",
|
"saving": "Enregistrement…",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.13.1",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.13.1",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user