feat!: discord login

This commit is contained in:
2024-05-19 21:30:27 +02:00
parent 7aac192af4
commit 1a84feeb43
14 changed files with 164 additions and 95 deletions

View File

@@ -22,6 +22,7 @@
"arctic": "^1.8.1", "arctic": "^1.8.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"discord-api-types": "^0.37.84",
"lucia": "^3.1.1", "lucia": "^3.1.1",
"lucide-react": "^0.368.0", "lucide-react": "^0.368.0",
"next": "^14.2.3", "next": "^14.2.3",

View File

@@ -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");

View File

@@ -15,9 +15,8 @@ datasource db {
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
username String @unique username String
discord_id String @unique discord_id String @unique
hashed_password String
isAdmin Boolean @default(false) isAdmin Boolean @default(false)
sessions Session[] sessions Session[]
bots Bot[] bots Bot[]

BIN
public/noBots.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

View File

@@ -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<Response> {
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
});
}
}

View File

@@ -0,0 +1,18 @@
import { generateState } from "arctic";
import { discord } from "@/lib/auth";
import { cookies } from "next/headers";
export async function GET(): Promise<Response> {
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);
}

View File

@@ -1,47 +1,19 @@
"use client"; "use client";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import Link from "next/link"; import Link from "next/link";
import { useFormState } from "react-dom"; import { Button } from "@/components/ui/button";
import { login } from "@/lib/auth/actions";
import SubmitButton from "@/components/app/SubmitButton/SubmitButton";
import { useEffect } from "react";
import { toast } from "sonner";
export default function Page() { export default function Page() {
const [formData, formAction] = useFormState(login, null);
useEffect(() => {
if (formData?.error) {
toast.error(formData.error)
}
}, [formData])
return ( return (
<div className="flex items-center p-4 lg:p-8"> <div className="flex items-center p-4 lg:p-8">
<div className="w-full max-w-md m-auto space-y-8"> <div className="w-full max-w-md m-auto space-y-8">
<div className="text-center"> <div className="text-center">
<h1 className="text-4xl font-bold pb-1">Log In</h1> <h1 className="text-4xl font-bold pb-1">Welcome!</h1>
</div>
<Link href="/auth/login/discord">
<Button className="w-full mt-8 bg-[#5865F2] hover:bg-[#626FFC]">Log in with Discord</Button>
</Link>
</div> </div>
<form action={formAction}>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Username</Label>
<Input name="username" id="username" required type="text" />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input name="password" id="password" required type="password" />
</div>
<SubmitButton buttonText="Log In" className="w-full" />
<div className="text-center text-sm">
No account?
<Link className="underline pl-1" href="/auth/signUp">
Create one!
</Link>
</div>
</div>
</form>
</div>
</div> </div>
); );
} }

View File

@@ -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 (
<div className="flex items-center p-6 lg:p-8">
<div className="w-full max-w-md m-auto space-y-8">
<div className="text-center">
<h1 className="text-4xl font-bold pb-1">Sign Up</h1>
</div>
<form action={signupAction}>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input name="username" required type="text" id="username" />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input name="password" required type="password" id="password" />
</div>
<SubmitButton buttonText="Create account" className="w-full" />
<div className="text-center text-sm">
Already have an account?
<Link className="underline pl-1" href="/auth/login">
Login
</Link>
</div>
</div>
</form>
</div>
</div>
)
}

View File

@@ -1,7 +1,11 @@
import UserCard from "@/components/app/UserCard/UserCard"; import UserCard from "@/components/app/UserCard/UserCard";
import { validateRequest } from "@/lib/auth"; import { validateRequest } from "@/lib/auth";
import prisma from "@/lib/db"; import prisma from "@/lib/db";
import Image from "next/image";
import NoBots from '../../../public/noBots.png'
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default async function Page() { export default async function Page() {
const { user } = await validateRequest() const { user } = await validateRequest()
@@ -17,6 +21,14 @@ export default async function Page() {
{dbFetch.map((bot) => ( {dbFetch.map((bot) => (
<UserCard key={bot.id} {...bot} /> <UserCard key={bot.id} {...bot} />
))} ))}
{dbFetch.length === 0 && (
<div className="flex items-center justify-center flex-col">
<Image src={NoBots} alt="Megamind meme with a caption saying No bots?" />
<Link href="/add">
<Button className="mt-8">Create a bot</Button>
</Link>
</div>
)}
</div> </div>
) )
} }

View File

@@ -35,7 +35,7 @@ function NavbarLinks() {
} }
export default function Navbar() { export default function Navbar() {
const { user } = useSession(); const { user, discord } = useSession();
const [, logoutAction] = useFormState(logout, null) const [, logoutAction] = useFormState(logout, null)
return ( return (
<> <>
@@ -54,8 +54,7 @@ export default function Navbar() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="cursor-pointer"> <DropdownMenuTrigger asChild className="cursor-pointer">
<Avatar> <Avatar>
{/* TODO: Implement avatar system */} <AvatarImage src={`https://cdn.discordapp.com/avatars/${discord?.id}/${discord?.avatar}.webp`} alt={`@${user.username}`} />
{/*<AvatarImage src={"https://srizan.dev/pfp.webp"} alt="@srizan" />*/}
<AvatarFallback>{user.username}</AvatarFallback> <AvatarFallback>{user.username}</AvatarFallback>
</Avatar> </Avatar>
</DropdownMenuTrigger> </DropdownMenuTrigger>

View File

@@ -3,20 +3,19 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { lucia, validateRequest } from "."; import { lucia, validateRequest } from ".";
import { redirect } from "next/navigation"; 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() { export async function logout() {
const { session } = await validateRequest(); const { session } = await validateRequest();
await lucia.invalidateSession(session!.id); if (!session) {
return redirect("/auth/login");
}
await lucia.invalidateSession(session.id);
const sessionCookie = lucia.createBlankSessionCookie(); const sessionCookie = lucia.createBlankSessionCookie();
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return redirect("/auth/login"); 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())) const checkSchema = await accountSchema.safeParseAsync(Object.fromEntries(data.entries()))
if (!checkSchema.success) if (!checkSchema.success)
return { return {
@@ -84,7 +83,7 @@ export async function signup(prev: any, formData: FormData): Promise<ActionResul
const sessionCookie = lucia.createSessionCookie(session.id); const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return redirect("/"); return redirect("/");
} } */
interface ActionResult { interface ActionResult {
error: string; error: string;

View File

@@ -4,6 +4,8 @@ import prisma from "../db";
import { cache } from "react"; import { cache } from "react";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { Discord } from 'arctic' import { Discord } from 'arctic'
import poster from '@sern/poster'
import { RESTGetAPIUserResult } from "discord-api-types/v10";
const adapter = new PrismaAdapter(prisma.session, prisma.user); const adapter = new PrismaAdapter(prisma.session, prisma.user);
@@ -20,12 +22,13 @@ export const lucia = new Lucia(adapter, {
getUserAttributes: (attributes) => { getUserAttributes: (attributes) => {
return { return {
username: attributes.username, 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 () => { export const validateRequest = cache(async () => {
const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null
@@ -34,6 +37,7 @@ export const validateRequest = cache(async () => {
return { return {
user: null, user: null,
session: null, session: null,
discord: null,
} }
const { user, session } = await lucia.validateSession(sessionId) const { user, session } = await lucia.validateSession(sessionId)
@@ -57,9 +61,15 @@ export const validateRequest = cache(async () => {
} catch { } catch {
// Next.js throws error attempting to set cookies when rendering page // 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 { return {
user, user,
session, session,
discord
} }
}) })
@@ -74,4 +84,5 @@ declare module "lucia" {
interface DatabaseUserAttributes { interface DatabaseUserAttributes {
username: string; username: string;
isAdmin: boolean; isAdmin: boolean;
discord_id: string;
} }

View File

@@ -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 // source: https://github.com/ugurkellecioglu/next-14-lucia-auth-postgresql-drizzle-typescript-example/blob/lucia-client-side/providers/Session.provider.tsx
"use client" "use client"
import { RESTGetAPIUserResult } from "discord-api-types/v10"
import { Session, User } from "lucia" import { Session, User } from "lucia"
import { createContext, useContext } from "react" import { createContext, useContext } from "react"
interface SessionProviderProps { interface SessionProviderProps {
user: User | null user: User | null
session: Session | null session: Session | null
discord: RESTGetAPIUserResult | null
} }
const SessionContext = createContext<SessionProviderProps>( const SessionContext = createContext<SessionProviderProps>(

View File

@@ -1327,6 +1327,11 @@ dir-glob@^3.0.1:
dependencies: dependencies:
path-type "^4.0.0" 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: dlv@^1.1.3:
version "1.1.3" version "1.1.3"
resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79"