AUTHMODE BABAY

This commit is contained in:
2024-08-18 12:34:43 -06:00
parent da044ac9d5
commit 729aba68ce
19 changed files with 1247 additions and 111 deletions

View File

@@ -4,6 +4,9 @@ WORKDIR /ttc
ADD . .
ENV NODE_ENV production
ENV AUTH_TRUST_HOST true
RUN npm i
RUN npm run build

12
actions/auth/index.ts Normal file
View File

@@ -0,0 +1,12 @@
"use server";
import { signIn, signOut } from "@/auth";
export const signInWithDiscord = async () => {
await signIn("discord");
};
export const signInWithCreds = async (formData: FormData) => {
await signIn("credentials", formData);
};
export const signOutOfApp = () => signOut();

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View File

@@ -1,21 +1,9 @@
"use client";
import { useToast } from "@/components/toast";
import { TTCMD } from "@/components/ttcmd";
import { useEffect } from "react";
import { FC, use } from "react";
export const HomeClient: FC<{ body: Promise<string> }> = ({ body }) => {
const text = use(body);
const { createToast } = useToast();
useEffect(() => {
createToast({
fading: false,
msg: "TEST TOAST",
type: "error",
});
}, [createToast]);
return <TTCMD body={text} parserId="home" title="home" />;
};

View File

@@ -11,7 +11,7 @@
}
input,
select {
@apply py-2 px-4 rounded-full dark:bg-mixed-200 bg-mixed-600 placeholder:text-dark-500;
@apply py-2 px-4 rounded-lg dark:bg-mixed-200 bg-mixed-600 placeholder:text-dark-500;
}
textarea {
@apply dark:bg-mixed-200 bg-primary-600 rounded-md p-1;
@@ -48,7 +48,7 @@
}
.btn {
@apply rounded-full;
@apply rounded-lg;
}
.btn-primary {
@apply dark:bg-primary-500 bg-primary-100 py-4 px-6 dark:text-mixed-100 text-white font-bold text-lg btn;
@@ -97,6 +97,17 @@
.fade-toast {
animation: fadeOut 300ms forwards;
}
.separated-list > li:not(:last-child) {
@apply border-b border-mixed-600 w-full;
}
.fade-menu {
animation: fadeIn 100ms forwards;
}
.fade-menu[data-closing="true"] {
animation: fadeOut 100ms forwards;
}
}
@keyframes identifier {
@@ -117,3 +128,11 @@
opacity: 0;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@@ -13,6 +13,8 @@ import { DevToolboxContextProvider } from "@/components/devtools/context";
import { RecoilRootClient } from "@/components/recoilRoot";
import { JotaiProvider } from "@/components/jotaiProvider";
import { Toaster } from "@/components/toast";
import { SessionProvider } from "next-auth/react";
import { User } from "@/components/user/index";
const roboto = Roboto({ subsets: ["latin"], weight: "400" });
@@ -56,37 +58,44 @@ export default function RootLayout({
return (
<html lang="en">
<body className={roboto.className + " flex min-h-[100vh]"}>
<nav className="h-[100vh] sticky top-0 left-0 bottom-0 p-8 rounded-r-3xl dark:bg-mixed-300 bg-primary-400 w-max shadow-2xl">
<h1 className="text-lg font-bold pb-6 border-b dark:border-dark-500 border-primary-600">
<Link href="/">Tabletop Commander</Link>
</h1>
<ul className="my-6 flex flex-col gap-6">
{navItems.map((n) => (
<li key={"nav-item" + n.text}>
<Link
href={n.to}
className="flex items-center gap-2 group hover:text-purple-300 transition-colors"
>
<n.icon className="w-6 h-6 group-hover:fill-purple-300 transition-colors" />
{n.text}
</Link>
</li>
))}
</ul>
</nav>
<RecoilRootClient>
<JotaiProvider>
<DevToolboxContextProvider
isDev={process.env.NODE_ENV !== "production"}
>
<main className="p-8 w-full overflow-visible">{children}</main>
<Toaster />
</DevToolboxContextProvider>
</JotaiProvider>
</RecoilRootClient>
<div id="root-portal"></div>
</body>
<SessionProvider>
<body className={roboto.className + " flex min-h-[100vh]"}>
<nav className="h-[100vh] sticky top-0 left-0 bottom-0 p-8 rounded-r-3xl dark:bg-mixed-300 bg-primary-400 w-max shadow-2xl">
<div className="flex flex-col h-full">
<h1 className="text-lg font-bold pb-6 border-b dark:border-dark-500 border-primary-600">
<Link href="/">Tabletop Commander</Link>
</h1>
<ul className="my-6 flex flex-col gap-6">
{navItems.map((n) => (
<li key={"nav-item" + n.text}>
<Link
href={n.to}
className="flex items-center gap-2 group hover:text-purple-300 transition-colors"
>
<n.icon className="w-6 h-6 group-hover:fill-purple-300 transition-colors" />
{n.text}
</Link>
</li>
))}
</ul>
<div className="mt-auto">
<User />
</div>
</div>
</nav>
<RecoilRootClient>
<JotaiProvider>
<DevToolboxContextProvider
isDev={process.env.NODE_ENV !== "production"}
>
<main className="p-8 w-full overflow-visible">{children}</main>
<Toaster />
</DevToolboxContextProvider>
</JotaiProvider>
</RecoilRootClient>
<div id="root-portal"></div>
</body>
</SessionProvider>
</html>
);
}

19
app/sign-in/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { auth } from "@/auth";
import SignIn from "@/components/signIn";
import { redirect } from "next/navigation";
async function SignInUp() {
const session = await auth();
if (session?.user) redirect("/");
return (
<div className="grid place-items-center h-full">
<div>
<SignIn />
</div>
</div>
);
}
export default SignInUp;

70
auth/index.ts Normal file
View File

@@ -0,0 +1,70 @@
import { PrismaAdapter } from "@auth/prisma-adapter";
import { PrismaClient } from "@prisma/client";
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import Discord from "next-auth/providers/discord";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Discord({
clientId: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
}),
Credentials({
credentials: {
email: {},
password: {},
},
authorize: async (credentials) => {
let user = null;
const pwHash = await saltAndHashPassword(
credentials.password as string
);
user = await prisma.user.findFirst({
where: {
email: credentials.email as string,
},
select: {
name: true,
image: true,
email: true,
emailVerified: true,
username: true,
passwordHash: true,
},
});
if (!user) {
user = await prisma.user.create({
data: {
email: credentials.email as string,
passwordHash: pwHash,
},
select: {
name: true,
image: true,
email: true,
emailVerified: true,
username: true,
},
});
return user;
}
user.passwordHash = null;
return user;
},
}),
],
adapter: PrismaAdapter(prisma),
});
async function saltAndHashPassword(password: string) {
const hash = await bcrypt.hash(password, 10);
return hash;
}

34
components/signIn.tsx Normal file
View File

@@ -0,0 +1,34 @@
import { signInWithCreds, signInWithDiscord } from "@/actions/auth";
import { Icon } from "./Icon";
export default function SignIn() {
return (
<div className="flex flex-col gap-2">
<form action={signInWithCreds} className="flex flex-col gap-2">
<input
className="w-full"
placeholder="email"
type="email"
name="email"
/>
<input
className="w-full"
placeholder="password"
type="password"
name="password"
/>
</form>
<div className="flex items-center gap-1">
<div className="dark:border-dark-500 border-primary-600 flex-grow border-b"></div>
<div className="dark:text-dark-500 text-primary-600 ">or</div>
<div className="dark:border-dark-500 border-primary-600 flex-grow border-b"></div>
</div>
<form action={signInWithDiscord}>
<button className="w-full p-2 bg-[#816ab1] rounded-lg" type="submit">
<Icon icon="Discord" className="mr-4 inline-block" />
Sign in with Discord
</button>
</form>
</div>
);
}

33
components/user/index.tsx Normal file
View File

@@ -0,0 +1,33 @@
/* eslint-disable @next/next/no-img-element */
import { auth } from "@/auth";
import { UserCircleIcon } from "@heroicons/react/24/solid";
import { FC } from "react";
import { UserMenu } from "./menu";
export const User: FC = async () => {
const session = await auth();
return (
<UserMenu signedIn={!!session?.user}>
<div className="flex gap-2 items-center">
{session?.user?.image ? (
<img
src={session.user.image}
alt="user avatar"
className="rounded-full w-12"
/>
) : (
<span className="w-12 h-12 inline-block">
<UserCircleIcon className="w-full h-full" />
</span>
)}
{session?.user?.name ? (
<span>Hey there, {session.user.name}!</span>
) : (
<a className="block flex-grow h-full" href="/sign-in">
Sign In
</a>
)}
</div>
</UserMenu>
);
};

50
components/user/menu.tsx Normal file
View File

@@ -0,0 +1,50 @@
"use client";
import { signOutOfApp } from "@/actions/auth";
import { FC, PropsWithChildren, useCallback, useState } from "react";
export const UserMenu: FC<PropsWithChildren<{ signedIn: boolean }>> = ({
children,
signedIn,
}) => {
const [visible, setVisible] = useState(false);
const [closing, setClosing] = useState(true);
const toggle = useCallback(() => {
setClosing((c) => !c);
setTimeout(
() => {
setVisible((v) => !v);
},
visible ? 100 : 0
);
}, [visible]);
return (
<div
onClick={signedIn ? toggle : undefined}
className="relative bg-mixed-200 p-2 rounded-lg cursor-pointer w-[220px]"
>
{visible && (
<div
data-closing={closing}
className="absolute bottom-full left-0 right-0 fade-menu"
>
<ul className="separated-list w-full">
<li>
<a className="block p-2" href="/profile">
Profile
</a>
</li>
<li>
<button className="p-2" onClick={() => signOutOfApp()}>
Sign Out
</button>
</li>
</ul>
</div>
)}
{children}
</div>
);
};

1
middleware.ts Normal file
View File

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

864
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,17 +6,23 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"postinstall": "bun ./postinstall/index.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^2.4.2",
"@heroicons/react": "^2.1.1",
"@prisma/client": "^5.18.0",
"@types/bcryptjs": "^2.4.6",
"bcryptjs": "^2.4.3",
"isomorphic-dompurify": "^2.4.0",
"jotai": "^2.9.3",
"next": "^14.2.5",
"next-auth": "^5.0.0-beta.20",
"react": "^18",
"react-dom": "^18",
"recoil": "^0.7.7"
"recoil": "^0.7.7",
"url-loader": "^4.1.1"
},
"devDependencies": {
"@svgr/webpack": "^8.1.0",

22
postinstall/buildEnv.ts Normal file
View File

@@ -0,0 +1,22 @@
const { SecretClient } = require("../lib/secret/init");
const { writeFile } = require("fs/promises");
const requiredKeys = [
"discord_client_id",
"discord_client_secret",
"ttc:database_url",
];
const secretClient = SecretClient();
async function buildEnv() {
secretClient.fetchToken();
let secrets = "";
for (const key of requiredKeys) {
const value = await secretClient.fetchSecret(key);
secrets += `${key.replace("ttc:", "").toUpperCase()}=${value}\n`;
}
await writeFile(".env", secrets, "utf-8");
}
buildEnv();

1
postinstall/index.ts Normal file
View File

@@ -0,0 +1 @@
require("./buildEnv.ts");

View File

@@ -0,0 +1,71 @@
/*
Warnings:
- A unique constraint covering the columns `[username]` on the table `User` will be added. If there are existing duplicate values, this will fail.
- Added the required column `updatedAt` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE `User` ADD COLUMN `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
ADD COLUMN `emailVerified` DATETIME(3) NULL,
ADD COLUMN `image` VARCHAR(191) NULL,
ADD COLUMN `name` VARCHAR(191) NULL,
ADD COLUMN `updatedAt` DATETIME(3) NOT NULL,
MODIFY `username` VARCHAR(191) NULL,
MODIFY `email` VARCHAR(191) NULL;
-- CreateTable
CREATE TABLE `Account` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`type` VARCHAR(191) NOT NULL,
`provider` VARCHAR(191) NOT NULL,
`providerAccountId` VARCHAR(191) NOT NULL,
`refresh_token` TEXT NULL,
`access_token` TEXT NULL,
`expires_at` INTEGER NULL,
`token_type` VARCHAR(191) NULL,
`scope` VARCHAR(191) NULL,
`id_token` TEXT NULL,
`session_state` VARCHAR(191) NULL,
`refresh_token_expires_in` INTEGER NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Account_userId_key`(`userId`),
INDEX `Account_userId_idx`(`userId`),
UNIQUE INDEX `Account_provider_providerAccountId_key`(`provider`, `providerAccountId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Session` (
`id` VARCHAR(191) NOT NULL,
`sessionToken` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`expires` DATETIME(3) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Session_sessionToken_key`(`sessionToken`),
INDEX `Session_userId_idx`(`userId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `VerificationToken` (
`identifier` VARCHAR(191) NOT NULL,
`token` VARCHAR(191) NOT NULL,
`expires` DATETIME(3) NOT NULL,
UNIQUE INDEX `VerificationToken_identifier_token_key`(`identifier`, `token`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateIndex
CREATE UNIQUE INDEX `User_username_key` ON `User`(`username`);
-- AddForeignKey
ALTER TABLE `Account` ADD CONSTRAINT `Account_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Session` ADD CONSTRAINT `Session_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `User` ADD COLUMN `passwordHash` VARCHAR(191) NULL;

View File

@@ -85,6 +85,59 @@ model User {
gameSystems GameSystem[]
publications Publication[]
username String
email String @unique
name String?
username String? @unique
email String? @unique
emailVerified DateTime?
passwordHash String?
image String?
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(cuid())
userId String @unique
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
refresh_token_expires_in Int?
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([provider, providerAccountId])
@@index([userId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
model VerificationToken {
identifier String
token String
expires DateTime
@@unique([identifier, token])
}