mirror of
https://github.com/sern-handler/frontpage-bot
synced 2026-06-06 01:16:54 +00:00
feat!: discord login
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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
BIN
public/noBots.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 201 KiB |
80
src/app/auth/login/discord/callback/route.ts
Normal file
80
src/app/auth/login/discord/callback/route.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/app/auth/login/discord/route.ts
Normal file
18
src/app/auth/login/discord/route.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user