diff --git a/package.json b/package.json index 8927691..4e0c9d4 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "arctic": "^1.8.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "discord-api-types": "^0.37.84", "lucia": "^3.1.1", "lucide-react": "^0.368.0", "next": "^14.2.3", diff --git a/prisma/migrations/20240519185618_add_discord_stuff/migration.sql b/prisma/migrations/20240519185618_add_discord_stuff/migration.sql new file mode 100644 index 0000000..9d7afe1 --- /dev/null +++ b/prisma/migrations/20240519185618_add_discord_stuff/migration.sql @@ -0,0 +1,17 @@ +/* + Warnings: + + - You are about to drop the column `hashed_password` on the `User` table. All the data in the column will be lost. + - A unique constraint covering the columns `[discord_id]` on the table `User` will be added. If there are existing duplicate values, this will fail. + - Added the required column `discord_id` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropIndex +DROP INDEX "User_username_key"; + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "hashed_password", +ADD COLUMN "discord_id" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "User_discord_id_key" ON "User"("discord_id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1b2a45b..49b8b49 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,9 +15,8 @@ datasource db { model User { id String @id @default(cuid()) - username String @unique + username String discord_id String @unique - hashed_password String isAdmin Boolean @default(false) sessions Session[] bots Bot[] diff --git a/public/noBots.png b/public/noBots.png new file mode 100644 index 0000000..a4e465a Binary files /dev/null and b/public/noBots.png differ diff --git a/src/app/auth/login/discord/callback/route.ts b/src/app/auth/login/discord/callback/route.ts new file mode 100644 index 0000000..48ffafa --- /dev/null +++ b/src/app/auth/login/discord/callback/route.ts @@ -0,0 +1,80 @@ +import { discord, lucia } from "@/lib/auth"; +import { cookies } from "next/headers"; +import { OAuth2RequestError } from "arctic"; +import { generateIdFromEntropySize } from "lucia"; +import prisma from "@/lib/db"; +import type { RESTGetAPICurrentUserResult } from "discord-api-types/v10"; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const storedState = cookies().get("discord_oauth_state")?.value ?? null; + if (!code || !state || !storedState || state !== storedState) { + return new Response(null, { + status: 400 + }); + } + + try { + const tokens = await discord.validateAuthorizationCode(code); + const discordUserResponse = await fetch("https://discord.com/api/users/@me", { + headers: { + Authorization: `Bearer ${tokens.accessToken}` + } + }); + + const discordUser: RESTGetAPICurrentUserResult = await discordUserResponse.json(); + + // Replace this with your own DB client. + const existingUser = await prisma.user.findFirst({ + where: { + discord_id: discordUser.id + } + }) + + if (existingUser) { + const session = await lucia.createSession(existingUser.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + return new Response(null, { + status: 302, + headers: { + Location: "/" + } + }); + } + + const userId = generateIdFromEntropySize(10); // 16 characters long + + // Replace this with your own DB client. + await prisma.user.create({ + data: { + id: userId, + discord_id: discordUser.id, + username: discordUser.username + } + }) + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + return new Response(null, { + status: 302, + headers: { + Location: "/" + } + }); + } catch (e) { + // the specific error message depends on the provider + if (e instanceof OAuth2RequestError) { + // invalid code + return new Response(null, { + status: 400 + }); + } + return new Response(null, { + status: 500 + }); + } +} \ No newline at end of file diff --git a/src/app/auth/login/discord/route.ts b/src/app/auth/login/discord/route.ts new file mode 100644 index 0000000..bab7452 --- /dev/null +++ b/src/app/auth/login/discord/route.ts @@ -0,0 +1,18 @@ +import { generateState } from "arctic"; +import { discord } from "@/lib/auth"; +import { cookies } from "next/headers"; + +export async function GET(): Promise { + const state = generateState(); + const url = await discord.createAuthorizationURL(state, { scopes: ['identify'] }); + + cookies().set("discord_oauth_state", state, { + path: "/", + secure: process.env.NODE_ENV === "production", + httpOnly: true, + maxAge: 60 * 10, + sameSite: "lax" + }); + + return Response.redirect(url); +} diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 69e5e0f..47eb539 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -1,47 +1,19 @@ "use client"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; + import Link from "next/link"; -import { useFormState } from "react-dom"; -import { login } from "@/lib/auth/actions"; -import SubmitButton from "@/components/app/SubmitButton/SubmitButton"; -import { useEffect } from "react"; -import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; export default function Page() { - const [formData, formAction] = useFormState(login, null); - useEffect(() => { - if (formData?.error) { - toast.error(formData.error) - } - }, [formData]) - return (
-

Log In

+

Welcome!

+
+ + +
-
-
-
- - -
-
- - -
- -
- No account? - - Create one! - -
-
-
-
); } diff --git a/src/app/auth/signUp/page.tsx b/src/app/auth/signUp/page.tsx deleted file mode 100644 index a59c3e9..0000000 --- a/src/app/auth/signUp/page.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'use client' -import { Label } from "@/components/ui/label" -import { Input } from "@/components/ui/input" -import Link from "next/link" -import { signup } from "@/lib/auth/actions"; -import SubmitButton from "@/components/app/SubmitButton/SubmitButton"; -import { useFormState } from "react-dom"; -import { toast } from "sonner"; -import { useEffect } from "react"; - -export default function Page() { - const [signupData, signupAction] = useFormState(signup, null) - useEffect(() => { - if (signupData?.error) { - toast.error(signupData.error) - } - }, [signupData]) - return ( -
-
-
-

Sign Up

-
-
-
-
- - -
-
- - -
- -
- Already have an account? - - Login - -
-
-
-
-
- ) -} \ No newline at end of file diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 3a2f90d..0e188a7 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,7 +1,11 @@ import UserCard from "@/components/app/UserCard/UserCard"; import { validateRequest } from "@/lib/auth"; import prisma from "@/lib/db"; +import Image from "next/image"; +import NoBots from '../../../public/noBots.png' import { redirect } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; export default async function Page() { const { user } = await validateRequest() @@ -17,6 +21,14 @@ export default async function Page() { {dbFetch.map((bot) => ( ))} + {dbFetch.length === 0 && ( +
+ Megamind meme with a caption saying No bots? + + + +
+ )} ) } \ No newline at end of file diff --git a/src/components/app/NavBar/NavBar.tsx b/src/components/app/NavBar/NavBar.tsx index b773fdb..8f884d3 100644 --- a/src/components/app/NavBar/NavBar.tsx +++ b/src/components/app/NavBar/NavBar.tsx @@ -35,7 +35,7 @@ function NavbarLinks() { } export default function Navbar() { - const { user } = useSession(); + const { user, discord } = useSession(); const [, logoutAction] = useFormState(logout, null) return ( <> @@ -54,8 +54,7 @@ export default function Navbar() { - {/* TODO: Implement avatar system */} - {/**/} + {user.username} diff --git a/src/lib/auth/actions.ts b/src/lib/auth/actions.ts index 4e66c2f..164390b 100644 --- a/src/lib/auth/actions.ts +++ b/src/lib/auth/actions.ts @@ -3,20 +3,19 @@ import { cookies } from "next/headers"; import { lucia, validateRequest } from "."; import { redirect } from "next/navigation"; -import prisma from "../db"; -import { Argon2id } from "oslo/password"; -import { generateId } from "lucia"; -import { accountSchema } from "./zod"; export async function logout() { const { session } = await validateRequest(); - await lucia.invalidateSession(session!.id); + if (!session) { + return redirect("/auth/login"); + } + await lucia.invalidateSession(session.id); const sessionCookie = lucia.createBlankSessionCookie(); cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); return redirect("/auth/login"); } -export async function login(prev: any, data: FormData) { +/* export async function login(prev: any, data: FormData) { const checkSchema = await accountSchema.safeParseAsync(Object.fromEntries(data.entries())) if (!checkSchema.success) return { @@ -84,7 +83,7 @@ export async function signup(prev: any, formData: FormData): Promise { return { username: attributes.username, - isAdmin: attributes.isAdmin + isAdmin: attributes.isAdmin, + discord_id: attributes.discord_id }; } }); -export const discord = new Discord(process.env.DSC_CLIENTID!, process.env.DSC_CLIENTSECRET!, process.env.NODE_ENV === "production" ? process.env.DSC_REDIRECTURI! : "http://localhost:3000/api/auth/discord/callback") +export const discord = new Discord(process.env.DSC_CLIENTID!, process.env.DSC_CLIENTSECRET!, process.env.NODE_ENV === "production" ? process.env.DSC_REDIRECTURI! : "http://localhost:3000/auth/login/discord/callback") export const validateRequest = cache(async () => { const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null @@ -34,6 +37,7 @@ export const validateRequest = cache(async () => { return { user: null, session: null, + discord: null, } const { user, session } = await lucia.validateSession(sessionId) @@ -57,9 +61,15 @@ export const validateRequest = cache(async () => { } catch { // Next.js throws error attempting to set cookies when rendering page } + const initDiscord = await poster.client(process.env.DSC_TOKEN!) + const discord = await (await initDiscord('user/get', { + user_id: user!.discord_id + })).json() as RESTGetAPIUserResult + return { user, session, + discord } }) @@ -74,4 +84,5 @@ declare module "lucia" { interface DatabaseUserAttributes { username: string; isAdmin: boolean; + discord_id: string; } \ No newline at end of file diff --git a/src/lib/providers/SessionProvider.tsx b/src/lib/providers/SessionProvider.tsx index 7e96bdf..8e8ed3f 100644 --- a/src/lib/providers/SessionProvider.tsx +++ b/src/lib/providers/SessionProvider.tsx @@ -1,12 +1,14 @@ // source: https://github.com/ugurkellecioglu/next-14-lucia-auth-postgresql-drizzle-typescript-example/blob/lucia-client-side/providers/Session.provider.tsx "use client" +import { RESTGetAPIUserResult } from "discord-api-types/v10" import { Session, User } from "lucia" import { createContext, useContext } from "react" interface SessionProviderProps { user: User | null session: Session | null + discord: RESTGetAPIUserResult | null } const SessionContext = createContext( diff --git a/yarn.lock b/yarn.lock index 3c15e05..d13f3a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1327,6 +1327,11 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +discord-api-types@^0.37.84: + version "0.37.84" + resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.37.84.tgz#cb11c562ae56d8fd35683a779bae567e4fcbd0a2" + integrity sha512-NngmTBW8vermlbO0qNtaS7SHCWB/R96ICqflTwM/cV7zsxyQGd38E2bBlwaxLbXgb2YTF3+Yx6+qGs/3sXedCw== + dlv@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79"