feat: usernames

also change userId to number
This commit is contained in:
trafficlunar 2025-03-30 17:36:49 +01:00
parent 9344f2f315
commit 9d35d93d9e
15 changed files with 200 additions and 78 deletions

View file

@ -10,12 +10,13 @@
"postinstall": "prisma generate" "postinstall": "prisma generate"
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.8.0", "@auth/prisma-adapter": "2.7.2",
"@prisma/client": "^6.5.0", "@prisma/client": "^6.5.0",
"next": "15.2.4", "next": "15.2.4",
"next-auth": "5.0.0-beta.25", "next-auth": "5.0.0-beta.25",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",

View file

@ -9,8 +9,8 @@ importers:
.: .:
dependencies: dependencies:
'@auth/prisma-adapter': '@auth/prisma-adapter':
specifier: ^2.8.0 specifier: 2.7.2
version: 2.8.0(@prisma/client@6.5.0(prisma@6.5.0(typescript@5.8.2))(typescript@5.8.2)) version: 2.7.2(@prisma/client@6.5.0(prisma@6.5.0(typescript@5.8.2))(typescript@5.8.2))
'@prisma/client': '@prisma/client':
specifier: ^6.5.0 specifier: ^6.5.0
version: 6.5.0(prisma@6.5.0(typescript@5.8.2))(typescript@5.8.2) version: 6.5.0(prisma@6.5.0(typescript@5.8.2))(typescript@5.8.2)
@ -26,6 +26,9 @@ importers:
react-dom: react-dom:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.0.0(react@19.0.0) version: 19.0.0(react@19.0.0)
zod:
specifier: ^3.24.2
version: 3.24.2
devDependencies: devDependencies:
'@eslint/eslintrc': '@eslint/eslintrc':
specifier: ^3 specifier: ^3
@ -81,24 +84,10 @@ packages:
nodemailer: nodemailer:
optional: true optional: true
'@auth/core@0.38.0': '@auth/prisma-adapter@2.7.2':
resolution: {integrity: sha512-ClHl44x4cY3wfJmHLpW+XrYqED0fZIzbHmwbExltzroCjR5ts3DLTWzADRba8mJFYZ8JIEJDa+lXnGl0E9Bl7Q==} resolution: {integrity: sha512-orznIVt6aQMoJ4/rfWFSpRPU8LoZn6jVtDuEkZgLud2xSnCalq6x+hX+rqlk4E5LM13NW1GIJojOPQnM4aM4Gw==}
peerDependencies: peerDependencies:
'@simplewebauthn/browser': ^9.0.1 '@prisma/client': '>=2.26.0 || >=3 || >=4 || >=5'
'@simplewebauthn/server': ^9.0.2
nodemailer: ^6.8.0
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
'@auth/prisma-adapter@2.8.0':
resolution: {integrity: sha512-g0Bmq3l5xUDyBBiDgm/y3Zqb582CnRHzFqbloV7scrLia5AbVC0xy+ntn+CQCAWW9ibpwiqJrQKKboIWN1oGqw==}
peerDependencies:
'@prisma/client': '>=2.26.0 || >=3 || >=4 || >=5 || >=6'
'@emnapi/core@1.4.0': '@emnapi/core@1.4.0':
resolution: {integrity: sha512-H+N/FqT07NmLmt6OFFtDfwe8PNygprzBikrEMyQfgqSmT0vzE515Pz7R8izwB9q/zsH/MA64AKoul3sA6/CzVg==} resolution: {integrity: sha512-H+N/FqT07NmLmt6OFFtDfwe8PNygprzBikrEMyQfgqSmT0vzE515Pz7R8izwB9q/zsH/MA64AKoul3sA6/CzVg==}
@ -1411,9 +1400,6 @@ packages:
jose@5.10.0: jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
jose@6.0.10:
resolution: {integrity: sha512-skIAxZqcMkOrSwjJvplIPYrlXGpxTPnro2/QWTDCxAdWQrSTV5/KqspMWmi5WAx5+ULswASJiZ0a+1B/Lxt9cw==}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -1691,17 +1677,9 @@ packages:
peerDependencies: peerDependencies:
preact: '>=10' preact: '>=10'
preact-render-to-string@6.5.11:
resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
peerDependencies:
preact: '>=10'
preact@10.11.3: preact@10.11.3:
resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==} resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==}
preact@10.24.3:
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
prelude-ls@1.2.1: prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@ -1996,6 +1974,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
zod@3.24.2:
resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==}
snapshots: snapshots:
'@alloc/quick-lru@5.2.0': {} '@alloc/quick-lru@5.2.0': {}
@ -2010,17 +1991,9 @@ snapshots:
preact: 10.11.3 preact: 10.11.3
preact-render-to-string: 5.2.3(preact@10.11.3) preact-render-to-string: 5.2.3(preact@10.11.3)
'@auth/core@0.38.0': '@auth/prisma-adapter@2.7.2(@prisma/client@6.5.0(prisma@6.5.0(typescript@5.8.2))(typescript@5.8.2))':
dependencies: dependencies:
'@panva/hkdf': 1.2.1 '@auth/core': 0.37.2
jose: 6.0.10
oauth4webapi: 3.3.2
preact: 10.24.3
preact-render-to-string: 6.5.11(preact@10.24.3)
'@auth/prisma-adapter@2.8.0(@prisma/client@6.5.0(prisma@6.5.0(typescript@5.8.2))(typescript@5.8.2))':
dependencies:
'@auth/core': 0.38.0
'@prisma/client': 6.5.0(prisma@6.5.0(typescript@5.8.2))(typescript@5.8.2) '@prisma/client': 6.5.0(prisma@6.5.0(typescript@5.8.2))(typescript@5.8.2)
transitivePeerDependencies: transitivePeerDependencies:
- '@simplewebauthn/browser' - '@simplewebauthn/browser'
@ -3417,8 +3390,6 @@ snapshots:
jose@5.10.0: {} jose@5.10.0: {}
jose@6.0.10: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@4.1.0: js-yaml@4.1.0:
@ -3670,14 +3641,8 @@ snapshots:
preact: 10.11.3 preact: 10.11.3
pretty-format: 3.8.0 pretty-format: 3.8.0
preact-render-to-string@6.5.11(preact@10.24.3):
dependencies:
preact: 10.24.3
preact@10.11.3: {} preact@10.11.3: {}
preact@10.24.3: {}
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
pretty-format@3.8.0: {} pretty-format@3.8.0: {}
@ -4081,3 +4046,5 @@ snapshots:
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
zod@3.24.2: {}

View file

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "miis" ADD COLUMN "tags" TEXT[];

View file

@ -1,7 +1,8 @@
-- CreateTable -- CreateTable
CREATE TABLE "users" ( CREATE TABLE "users" (
"id" TEXT NOT NULL, "id" SERIAL NOT NULL,
"name" TEXT, "username" TEXT,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL, "email" TEXT NOT NULL,
"emailVerified" TIMESTAMP(3), "emailVerified" TIMESTAMP(3),
"image" TEXT, "image" TEXT,
@ -13,7 +14,7 @@ CREATE TABLE "users" (
-- CreateTable -- CreateTable
CREATE TABLE "accounts" ( CREATE TABLE "accounts" (
"userId" TEXT NOT NULL, "userId" INTEGER NOT NULL,
"type" TEXT NOT NULL, "type" TEXT NOT NULL,
"provider" TEXT NOT NULL, "provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL, "providerAccountId" TEXT NOT NULL,
@ -33,7 +34,7 @@ CREATE TABLE "accounts" (
-- CreateTable -- CreateTable
CREATE TABLE "sessions" ( CREATE TABLE "sessions" (
"sessionToken" TEXT NOT NULL, "sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL, "userId" INTEGER NOT NULL,
"expires" TIMESTAMP(3) NOT NULL, "expires" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL "updatedAt" TIMESTAMP(3) NOT NULL
@ -41,10 +42,11 @@ CREATE TABLE "sessions" (
-- CreateTable -- CreateTable
CREATE TABLE "miis" ( CREATE TABLE "miis" (
"id" BIGSERIAL NOT NULL, "id" SERIAL NOT NULL,
"userId" TEXT NOT NULL, "userId" INTEGER NOT NULL,
"name" TEXT NOT NULL, "name" VARCHAR(64) NOT NULL,
"pictures" TEXT[], "pictures" TEXT[],
"tags" TEXT[],
"likes" INTEGER NOT NULL DEFAULT 0, "likes" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
@ -53,12 +55,15 @@ CREATE TABLE "miis" (
-- CreateTable -- CreateTable
CREATE TABLE "likes" ( CREATE TABLE "likes" (
"userId" TEXT NOT NULL, "userId" INTEGER NOT NULL,
"miiId" BIGINT NOT NULL, "miiId" INTEGER NOT NULL,
CONSTRAINT "likes_pkey" PRIMARY KEY ("userId","miiId") CONSTRAINT "likes_pkey" PRIMARY KEY ("userId","miiId")
); );
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); CREATE UNIQUE INDEX "users_email_key" ON "users"("email");

View file

@ -8,8 +8,9 @@ datasource db {
} }
model User { model User {
id String @id @default(cuid()) id Int @id @default(autoincrement())
name String? username String? @unique
name String
email String @unique email String @unique
emailVerified DateTime? emailVerified DateTime?
image String? image String?
@ -26,7 +27,7 @@ model User {
} }
model Account { model Account {
userId String userId Int
type String type String
provider String provider String
providerAccountId String providerAccountId String
@ -49,7 +50,7 @@ model Account {
model Session { model Session {
sessionToken String @unique sessionToken String @unique
userId String userId Int
expires DateTime expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@ -60,8 +61,8 @@ model Session {
} }
model Mii { model Mii {
id BigInt @id @default(autoincrement()) id Int @id @default(autoincrement())
userId String userId Int
name String @db.VarChar(64) name String @db.VarChar(64)
pictures String[] pictures String[]
tags String[] tags String[]
@ -76,8 +77,8 @@ model Mii {
} }
model Like { model Like {
userId String userId Int
miiId BigInt miiId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
mii Mii @relation(fields: [miiId], references: [id], onDelete: Cascade) mii Mii @relation(fields: [miiId], references: [id], onDelete: Cascade)

View file

@ -0,0 +1,38 @@
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");
export async function GET() {
const session = await auth();
if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 });
return Response.json({ username: session.user.username });
}
export async function PATCH(request: Request) {
const session = await auth();
if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { username } = await request.json();
if (!username) return Response.json({ error: "Username is required" }, { status: 400 });
const validation = usernameSchema.safeParse(username);
if (!validation.success) return Response.json({ error: validation.error.errors[0].message }, { status: 400 });
const existingUser = await prisma.user.findUnique({ where: { username } });
if (existingUser) return Response.json({ error: "Username is already taken" }, { status: 400 });
await prisma.user.update({
where: { email: session.user?.email ?? undefined },
data: { username },
});
return Response.json({ success: true });
}

View file

@ -23,7 +23,7 @@ export default function LikeButton({ likes, isLoggedIn }: Props) {
}; };
return ( return (
<button onClick={onClick} className="flex items-center gap-2 text-xl text-red-400 mt-1 cursor-pointer"> <button onClick={onClick} className="flex items-center gap-2 text-xl text-red-400 cursor-pointer">
<Icon icon={isLiked ? "icon-park-solid:like" : "icon-park-outline:like"} /> <Icon icon={isLiked ? "icon-park-solid:like" : "icon-park-outline:like"} />
<span>{likesState}</span> <span>{likesState}</span>
</button> </button>

View file

@ -6,11 +6,17 @@ import { signIn } from "next-auth/react";
export default function LoginButtons() { export default function LoginButtons() {
return ( return (
<div className="flex flex-col items-center gap-2 mt-8"> <div className="flex flex-col items-center gap-2 mt-8">
<button onClick={() => signIn("discord")} className="pill button gap-2 !px-3 !bg-indigo-400 !border-indigo-500 hover:!bg-indigo-500"> <button
onClick={() => signIn("discord", { redirectTo: "/create-username" })}
className="pill button gap-2 !px-3 !bg-indigo-400 !border-indigo-500 hover:!bg-indigo-500"
>
<Icon icon="ic:baseline-discord" fontSize={32} /> <Icon icon="ic:baseline-discord" fontSize={32} />
Login with Discord Login with Discord
</button> </button>
<button onClick={() => signIn("github")} className="pill button gap-2 !px-3 !bg-zinc-700 !border-zinc-800 hover:!bg-zinc-800 text-white"> <button
onClick={() => signIn("github", { redirectTo: "/create-username" })}
className="pill button gap-2 !px-3 !bg-zinc-700 !border-zinc-800 hover:!bg-zinc-800 text-white"
>
<Icon icon="mdi:github" fontSize={32} /> <Icon icon="mdi:github" fontSize={32} />
Login with GitHub Login with GitHub
</button> </button>

View file

@ -7,7 +7,7 @@ export default async function ProfileOverview() {
<li title="Your profile"> <li title="Your profile">
<button className="pill button !gap-2 !p-0 h-full max-w-64"> <button className="pill button !gap-2 !p-0 h-full max-w-64">
<img src={session?.user?.image ?? "/missing.webp"} alt="profile picture" className="rounded-full h-full outline-2 outline-orange-400" /> <img src={session?.user?.image ?? "/missing.webp"} alt="profile picture" className="rounded-full h-full outline-2 outline-orange-400" />
<span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{session?.user?.name}</span> <span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{session?.user?.username ?? "unknown"}</span>
</button> </button>
</li> </li>
); );

View file

@ -0,0 +1,45 @@
"use client";
import { FormEvent, useState } from "react";
import { redirect } from "next/navigation";
export default function UsernameForm() {
const [username, setUsername] = useState("");
const [error, setError] = useState(null);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
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();
setError(error);
return;
}
redirect("/");
};
return (
<form onSubmit={handleSubmit} className="flex flex-col items-center">
<input
type="text"
placeholder="Type your username..."
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="pill !bg-orange-200 outline-0 focus:ring-[3px] ring-orange-400/50 transition w-96 mt-8 mb-2"
/>
<button type="submit" className="pill button w-min">
Submit
</button>
{error && <p className="text-red-400 font-semibold mt-4">Error: {error}</p>}
</form>
);
}

View file

@ -0,0 +1,22 @@
import { auth } from "@/lib/auth";
import UsernameForm from "../components/username-form";
import { redirect } from "next/navigation";
export default async function CreateUsernamePage() {
const session = await auth();
// If the user is not logged in or already has a username, redirect
if (!session || session?.user.username) {
redirect("/");
}
return (
<div>
<h1 className="text-3xl font-medium text-center">Welcome to TomodachiShare!</h1>
<h2 className="text-lg text-center">Please create a username</h2>
<UsernameForm />
</div>
);
}

View file

@ -1 +0,0 @@
export { auth as middleware } from "@/lib/auth";

View file

@ -1,3 +1,4 @@
import { redirect } from "next/navigation";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
@ -36,8 +37,19 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
const miis = await prisma.mii.findMany({ const miis = await prisma.mii.findMany({
where: where, where: where,
orderBy, orderBy,
include: {
user: {
select: {
username: true,
},
},
},
}); });
if (session?.user && !session.user.username) {
redirect("/create-username");
}
return ( return (
<div className="w-full"> <div className="w-full">
<div className="flex justify-between items-end mb-2"> <div className="flex justify-between items-end mb-2">
@ -74,20 +86,24 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
{miis.map((mii) => ( {miis.map((mii) => (
<div <div
key={mii.id} key={mii.id}
className="bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3 transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600" className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3 transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600"
> >
<img src="https://placehold.co/600x400" alt="mii" className="rounded-xl" /> <img src="https://placehold.co/600x400" alt="mii" className="rounded-xl" />
<div className="p-4"> <div className="p-4 flex flex-col gap-1 h-full">
<h3 className="font-bold text-2xl overflow-hidden text-ellipsis line-clamp-2" title={mii.name}> <h3 className="font-bold text-2xl overflow-hidden text-ellipsis line-clamp-2" title={mii.name}>
{mii.name} {mii.name}
</h3> </h3>
<div id="tags" className="flex gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs"> <div id="tags" className="flex gap-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">
{mii.tags.map((tag) => ( {mii.tags.map((tag) => (
<span key={tag}>{tag}</span> <span key={tag}>{tag}</span>
))} ))}
</div> </div>
<div className="mt-auto grid grid-cols-2 items-center">
<LikeButton likes={mii.likes} isLoggedIn={session?.user != null} /> <LikeButton likes={mii.likes} isLoggedIn={session?.user != null} />
<span className="text-sm text-right text-ellipsis">@{mii.user?.username}</span>
</div>
</div> </div>
</div> </div>
))} ))}

View file

@ -11,4 +11,14 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
pages: { pages: {
signIn: "/login", signIn: "/login",
}, },
callbacks: {
async session({ session, user }) {
if (user) {
session.user.id = user.id;
session.user.username = user.username;
session.user.email = user.email;
}
return session;
},
},
}); });

14
src/types.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
import { DefaultSession, User } from "next-auth";
import { User as PrismaUser } from "@prisma/client";
declare module "next-auth" {
interface Session {
user: {
username?: string;
} & DefaultSession["user"];
}
interface User {
username?: string;
}
}