feat: ability to update display name and username in profile settings
This commit is contained in:
parent
86c76df873
commit
8b8c9aaa4b
10 changed files with 156 additions and 38 deletions
|
|
@ -16,6 +16,7 @@
|
|||
"@prisma/client": "^6.6.0",
|
||||
"@trafficlunar/asmcrypto.js": "^1.0.2",
|
||||
"bit-buffer": "^0.2.5",
|
||||
"dayjs": "^1.11.13",
|
||||
"downshift": "^9.0.9",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"jsqr": "^1.4.0",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ importers:
|
|||
bit-buffer:
|
||||
specifier: ^0.2.5
|
||||
version: 0.2.5
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
downshift:
|
||||
specifier: ^9.0.9
|
||||
version: 9.0.9(react@19.1.0)
|
||||
|
|
@ -1117,6 +1120,9 @@ packages:
|
|||
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
dayjs@1.11.13:
|
||||
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
|
||||
|
||||
debug@3.2.7:
|
||||
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -3138,6 +3144,8 @@ snapshots:
|
|||
es-errors: 1.3.0
|
||||
is-data-view: 1.0.2
|
||||
|
||||
dayjs@1.11.13: {}
|
||||
|
||||
debug@3.2.7:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
|
|
|||
28
src/app/api/auth/display-name/route.ts
Normal file
28
src/app/api/auth/display-name/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -1,21 +1,15 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
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");
|
||||
import { usernameSchema } from "@/lib/schemas";
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
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);
|
||||
if (!validation.success) return NextResponse.json({ error: validation.error.errors[0].message }, { status: 400 });
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes }: Props) {
|
|||
}
|
||||
|
||||
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 = () => {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useState } from "react";
|
||||
import SubmitDialogButton from "./submit-dialog-button";
|
||||
import DeleteAccount from "./delete-account";
|
||||
|
||||
interface Props {
|
||||
name: string | null | undefined;
|
||||
username: string | null | undefined;
|
||||
import { displayNameSchema, usernameSchema } from "@/lib/schemas";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default function ProfileSettings() {
|
||||
const router = useRouter();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export default function ProfileSettings({ name, username }: Props) {
|
||||
const [displayName, setDisplayName] = useState(name ?? "");
|
||||
const [usernameState, setUsernameState] = useState(username ?? "");
|
||||
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 (
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-4 flex flex-col gap-4">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{/* Separator */}
|
||||
|
|
@ -37,15 +90,22 @@ export default function ProfileSettings({ name, username }: Props) {
|
|||
</div>
|
||||
|
||||
<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
|
||||
title="Confirm Display Name Change"
|
||||
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">
|
||||
<p className="font-semibold">New display name:</p>
|
||||
<p className="indent-4">"{name}"</p>
|
||||
<p className="indent-4">'{displayName}'</p>
|
||||
</div>
|
||||
</SubmitDialogButton>
|
||||
</div>
|
||||
|
|
@ -65,19 +125,26 @@ export default function ProfileSettings({ name, username }: Props) {
|
|||
<input
|
||||
type="text"
|
||||
className="pill input w-full max-w-64 indent-4"
|
||||
value={usernameState}
|
||||
onChange={(e) => setUsernameState(e.target.value)}
|
||||
placeholder="Type here..."
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<span className="absolute top-1/2 -translate-y-1/2 left-4 select-none">@</span>
|
||||
</div>
|
||||
<SubmitDialogButton
|
||||
title="Confirm Username Change"
|
||||
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">
|
||||
<p className="font-semibold">New username:</p>
|
||||
<p className="indent-4">"@{usernameState}"</p>
|
||||
<p className="indent-4">'@{username}'</p>
|
||||
</div>
|
||||
</SubmitDialogButton>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Icon } from "@iconify/react";
|
|||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
onSubmit: () => void;
|
||||
onSubmit: (close: () => void) => void;
|
||||
error?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
|
@ -17,9 +17,7 @@ export default function SubmitDialogButton({ title, description, onSubmit, error
|
|||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const submit = () => {
|
||||
onSubmit();
|
||||
close();
|
||||
window.location.reload(); // I would use router.refresh() here but the API data fetching breaks
|
||||
onSubmit(close);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { usernameSchema } from "@/lib/schemas";
|
||||
|
||||
export default function UsernameForm() {
|
||||
const [username, setUsername] = useState("");
|
||||
|
|
@ -10,6 +11,9 @@ export default function UsernameForm() {
|
|||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const parsed = usernameSchema.safeParse(username);
|
||||
if (!parsed.success) setError(parsed.error.errors[0].message);
|
||||
|
||||
const response = await fetch("/api/auth/username", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export default async function ProfileSettingsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ProfileSettings name={session.user.name} username={session.user.username} />
|
||||
<ProfileSettings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,5 @@
|
|||
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
|
||||
.string()
|
||||
.trim()
|
||||
|
|
@ -18,6 +9,16 @@ export const querySchema = z
|
|||
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
|
||||
.array(
|
||||
z
|
||||
|
|
@ -30,3 +31,20 @@ export const tagsSchema = z
|
|||
)
|
||||
.min(1, { message: "There must be at least 1 tag" })
|
||||
.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.",
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue