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",
|
"@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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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 { 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 });
|
||||||
|
|
|
||||||
|
|
@ -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 = () => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 = () => {
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue