mirror of
https://github.com/sern-handler/frontpage-bot
synced 2026-06-05 17:06:51 +00:00
feat!: discord login
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
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[]
|
||||
|
||||
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";
|
||||
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 (
|
||||
<div className="flex items-center p-4 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">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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { 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) => (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="cursor-pointer">
|
||||
<Avatar>
|
||||
{/* TODO: Implement avatar system */}
|
||||
{/*<AvatarImage src={"https://srizan.dev/pfp.webp"} alt="@srizan" />*/}
|
||||
<AvatarImage src={`https://cdn.discordapp.com/avatars/${discord?.id}/${discord?.avatar}.webp`} alt={`@${user.username}`} />
|
||||
<AvatarFallback>{user.username}</AvatarFallback>
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -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<ActionResul
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
return redirect("/");
|
||||
}
|
||||
} */
|
||||
|
||||
interface ActionResult {
|
||||
error: string;
|
||||
|
||||
@@ -4,6 +4,8 @@ import prisma from "../db";
|
||||
import { cache } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { Discord } from 'arctic'
|
||||
import poster from '@sern/poster'
|
||||
import { RESTGetAPIUserResult } from "discord-api-types/v10";
|
||||
|
||||
const adapter = new PrismaAdapter(prisma.session, prisma.user);
|
||||
|
||||
@@ -20,12 +22,13 @@ export const lucia = new Lucia(adapter, {
|
||||
getUserAttributes: (attributes) => {
|
||||
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;
|
||||
}
|
||||
@@ -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<SessionProviderProps>(
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user