feat: ability to update display name and username in profile settings

This commit is contained in:
trafficlunar 2025-04-18 16:23:02 +01:00
parent 86c76df873
commit 8b8c9aaa4b
10 changed files with 156 additions and 38 deletions

View file

@ -16,6 +16,7 @@
"@prisma/client": "^6.6.0", "@prisma/client": "^6.6.0",
"@trafficlunar/asmcrypto.js": "^1.0.2", "@trafficlunar/asmcrypto.js": "^1.0.2",
"bit-buffer": "^0.2.5", "bit-buffer": "^0.2.5",
"dayjs": "^1.11.13",
"downshift": "^9.0.9", "downshift": "^9.0.9",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",

View file

@ -26,6 +26,9 @@ importers:
bit-buffer: bit-buffer:
specifier: ^0.2.5 specifier: ^0.2.5
version: 0.2.5 version: 0.2.5
dayjs:
specifier: ^1.11.13
version: 1.11.13
downshift: downshift:
specifier: ^9.0.9 specifier: ^9.0.9
version: 9.0.9(react@19.1.0) version: 9.0.9(react@19.1.0)
@ -1117,6 +1120,9 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
debug@3.2.7: debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies: peerDependencies:
@ -3138,6 +3144,8 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
is-data-view: 1.0.2 is-data-view: 1.0.2
dayjs@1.11.13: {}
debug@3.2.7: debug@3.2.7:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3

View file

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { displayNameSchema } from "@/lib/schemas";
export async function PATCH(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { displayName } = await request.json();
if (!displayName) return NextResponse.json({ error: "New display name is required" }, { status: 400 });
const validation = displayNameSchema.safeParse(displayName);
if (!validation.success) return NextResponse.json({ error: validation.error.errors[0].message }, { status: 400 });
try {
await prisma.user.update({
where: { email: session.user?.email ?? undefined },
data: { name: displayName },
});
} catch (error) {
console.error("Failed to update display name:", error);
return NextResponse.json({ error: "Failed to update display name" }, { status: 500 });
}
return NextResponse.json({ success: true });
}

View file

@ -1,21 +1,15 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { usernameSchema } from "@/lib/schemas";
const usernameSchema = z
.string()
.min(3, "Username must be at least 3 characters long")
.max(20, "Username cannot be more than 20 characters long")
.regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores");
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { username } = await request.json(); const { username } = await request.json();
if (!username) return NextResponse.json({ error: "Username is required" }, { status: 400 }); if (!username) return NextResponse.json({ error: "New username is required" }, { status: 400 });
const validation = usernameSchema.safeParse(username); const validation = usernameSchema.safeParse(username);
if (!validation.success) return NextResponse.json({ error: validation.error.errors[0].message }, { status: 400 }); if (!validation.success) return NextResponse.json({ error: validation.error.errors[0].message }, { status: 400 });

View file

@ -29,7 +29,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes }: Props) {
} }
close(); close();
window.location.reload(); // I would use router.refresh() here but the API data fetching breaks window.location.reload(); // I would use router.refresh() here but the Mii list doesn't update
}; };
const close = () => { const close = () => {

View file

@ -1,23 +1,76 @@
"use client"; "use client";
import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import SubmitDialogButton from "./submit-dialog-button"; import SubmitDialogButton from "./submit-dialog-button";
import DeleteAccount from "./delete-account"; import DeleteAccount from "./delete-account";
interface Props { import { displayNameSchema, usernameSchema } from "@/lib/schemas";
name: string | null | undefined; import dayjs from "dayjs";
username: string | null | undefined;
}
export default function ProfileSettings({ name, username }: Props) { export default function ProfileSettings() {
const [displayName, setDisplayName] = useState(name ?? ""); const router = useRouter();
const [usernameState, setUsernameState] = useState(username ?? "");
const [displayName, setDisplayName] = useState("");
const [username, setUsername] = useState("");
const [displayNameChangeError, setDisplayNameChangeError] = useState<string | undefined>(undefined);
const [usernameChangeError, setUsernameChangeError] = useState<string | undefined>(undefined);
const usernameDate = dayjs().add(90, "days");
const handleSubmitDisplayNameChange = async (close: () => void) => {
const parsed = displayNameSchema.safeParse(displayName);
if (!parsed.success) {
setDisplayNameChangeError(parsed.error.errors[0].message);
return;
}
const response = await fetch("/api/auth/display-name", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayName }),
});
if (!response.ok) {
const { error } = await response.json();
setDisplayNameChangeError(error);
return;
}
close();
router.refresh();
};
const handleSubmitUsernameChange = async (close: () => void) => {
const parsed = usernameSchema.safeParse(username);
if (!parsed.success) {
setUsernameChangeError(parsed.error.errors[0].message);
return;
}
const response = await fetch("/api/auth/username", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
if (!response.ok) {
const { error } = await response.json();
setUsernameChangeError(error);
return;
}
close();
router.refresh();
};
return ( return (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-4 flex flex-col gap-4"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-4 flex flex-col gap-4">
<div> <div>
<h2 className="text-2xl font-bold">Profile Settings</h2> <h2 className="text-2xl font-bold">Profile Settings</h2>
<p className="text-sm text-zinc-500">Update your account info, username, and preferences.</p> <p className="text-sm text-zinc-500">Update your account info, and username.</p>
</div> </div>
{/* Separator */} {/* Separator */}
@ -37,15 +90,22 @@ export default function ProfileSettings({ name, username }: Props) {
</div> </div>
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
<input type="text" className="pill input w-full max-w-64" value={displayName} onChange={(e) => setDisplayName(e.target.value)} /> <input
type="text"
className="pill input w-full max-w-64"
placeholder="Type here..."
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<SubmitDialogButton <SubmitDialogButton
title="Confirm Display Name Change" title="Confirm Display Name Change"
description="Update your display name? This will only be visible on your profile. You can change it again later." description="Update your display name? This will only be visible on your profile. You can change it again later."
onSubmit={() => {}} error={displayNameChangeError}
onSubmit={handleSubmitDisplayNameChange}
> >
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 px-2 py-1"> <div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 px-2 py-1">
<p className="font-semibold">New display name:</p> <p className="font-semibold">New display name:</p>
<p className="indent-4">"{name}"</p> <p className="indent-4">'{displayName}'</p>
</div> </div>
</SubmitDialogButton> </SubmitDialogButton>
</div> </div>
@ -65,19 +125,26 @@ export default function ProfileSettings({ name, username }: Props) {
<input <input
type="text" type="text"
className="pill input w-full max-w-64 indent-4" className="pill input w-full max-w-64 indent-4"
value={usernameState} placeholder="Type here..."
onChange={(e) => setUsernameState(e.target.value)} value={username}
onChange={(e) => setUsername(e.target.value)}
/> />
<span className="absolute top-1/2 -translate-y-1/2 left-4 select-none">@</span> <span className="absolute top-1/2 -translate-y-1/2 left-4 select-none">@</span>
</div> </div>
<SubmitDialogButton <SubmitDialogButton
title="Confirm Username Change" title="Confirm Username Change"
description="Are you sure? Your username is your unique indentifier and can only be changed every 90 days." description="Are you sure? Your username is your unique indentifier and can only be changed every 90 days."
onSubmit={() => {}} error={usernameChangeError}
onSubmit={handleSubmitUsernameChange}
> >
<p className="text-sm text-zinc-500 mt-2">
After submitting, you can change it again on{" "}
{usernameDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}.
</p>
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 px-2 py-1"> <div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 px-2 py-1">
<p className="font-semibold">New username:</p> <p className="font-semibold">New username:</p>
<p className="indent-4">"@{usernameState}"</p> <p className="indent-4">'@{username}'</p>
</div> </div>
</SubmitDialogButton> </SubmitDialogButton>
</div> </div>

View file

@ -7,7 +7,7 @@ import { Icon } from "@iconify/react";
interface Props { interface Props {
title: string; title: string;
description: string; description: string;
onSubmit: () => void; onSubmit: (close: () => void) => void;
error?: string; error?: string;
children?: React.ReactNode; children?: React.ReactNode;
} }
@ -17,9 +17,7 @@ export default function SubmitDialogButton({ title, description, onSubmit, error
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const submit = () => { const submit = () => {
onSubmit(); onSubmit(close);
close();
window.location.reload(); // I would use router.refresh() here but the API data fetching breaks
}; };
const close = () => { const close = () => {

View file

@ -2,6 +2,7 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { usernameSchema } from "@/lib/schemas";
export default function UsernameForm() { export default function UsernameForm() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@ -10,6 +11,9 @@ export default function UsernameForm() {
const handleSubmit = async (event: FormEvent) => { const handleSubmit = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
const parsed = usernameSchema.safeParse(username);
if (!parsed.success) setError(parsed.error.errors[0].message);
const response = await fetch("/api/auth/username", { const response = await fetch("/api/auth/username", {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },

View file

@ -46,7 +46,7 @@ export default async function ProfileSettingsPage() {
</div> </div>
</div> </div>
<ProfileSettings name={session.user.name} username={session.user.username} /> <ProfileSettings />
</div> </div>
); );
} }

View file

@ -1,14 +1,5 @@
import { z } from "zod"; import { z } from "zod";
export const nameSchema = z
.string()
.trim()
.min(2, { message: "Name must be at least 2 characters long" })
.max(64, { message: "Name cannot be more than 64 characters long" })
.regex(/^[a-zA-Z0-9-_. ']+$/, {
message: "Name can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.",
});
export const querySchema = z export const querySchema = z
.string() .string()
.trim() .trim()
@ -18,6 +9,16 @@ export const querySchema = z
message: "Search query can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.", message: "Search query can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.",
}); });
// Miis
export const nameSchema = z
.string()
.trim()
.min(2, { message: "Name must be at least 2 characters long" })
.max(64, { message: "Name cannot be more than 64 characters long" })
.regex(/^[a-zA-Z0-9-_. ']+$/, {
message: "Name can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.",
});
export const tagsSchema = z export const tagsSchema = z
.array( .array(
z z
@ -30,3 +31,20 @@ export const tagsSchema = z
) )
.min(1, { message: "There must be at least 1 tag" }) .min(1, { message: "There must be at least 1 tag" })
.max(8, { message: "There cannot be more than 8 tags" }); .max(8, { message: "There cannot be more than 8 tags" });
// Account Info
export const usernameSchema = z
.string()
.trim()
.min(3, "Username must be at least 3 characters long")
.max(20, "Username cannot be more than 20 characters long")
.regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores");
export const displayNameSchema = z
.string()
.trim()
.min(2, { message: "Display name must be at least 2 characters long" })
.max(64, { message: "Display name cannot be more than 64 characters long" })
.regex(/^[a-zA-Z0-9-_. ']+$/, {
message: "Display name can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.",
});