From 83bc98115225524a492a092582a2f0a03bc8bad8 Mon Sep 17 00:00:00 2001 From: Jane Petrovna Date: Sat, 2 Apr 2022 13:45:06 -0400 Subject: [PATCH] help me --- .env.example | 2 - app/discord/index.ts | 124 + app/models/note.server.ts | 53 - app/models/user.server.ts | 84 +- app/root.tsx | 14 +- app/routes/index.tsx | 144 +- app/routes/join.tsx | 179 - app/routes/login.tsx | 169 +- app/routes/loginstart.tsx | 25 + app/routes/notes.tsx | 73 - app/routes/notes/$noteId.tsx | 68 - app/routes/notes/index.tsx | 12 - app/routes/notes/new.tsx | 116 - app/session.server.ts | 37 +- app/utils.test.ts | 13 - app/utils.ts | 7 +- package.json | 4 +- pnpm-lock.yaml | 8721 +++++++++++++++++ .../migration.sql | 42 + prisma/schema.prisma | 33 +- prisma/seed.ts | 32 +- public/sky.png | Bin 0 -> 121202 bytes styles/tailwind.css | 9 + 23 files changed, 9034 insertions(+), 927 deletions(-) delete mode 100644 .env.example create mode 100644 app/discord/index.ts delete mode 100644 app/models/note.server.ts delete mode 100644 app/routes/join.tsx create mode 100644 app/routes/loginstart.tsx delete mode 100644 app/routes/notes.tsx delete mode 100644 app/routes/notes/$noteId.tsx delete mode 100644 app/routes/notes/index.tsx delete mode 100644 app/routes/notes/new.tsx delete mode 100644 app/utils.test.ts create mode 100644 pnpm-lock.yaml create mode 100644 prisma/migrations/20220322231835_discord_oauth/migration.sql create mode 100644 public/sky.png create mode 100644 styles/tailwind.css diff --git a/.env.example b/.env.example deleted file mode 100644 index 2259bc5..0000000 --- a/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" -SESSION_SECRET="super-duper-s3cret" diff --git a/app/discord/index.ts b/app/discord/index.ts new file mode 100644 index 0000000..74652dd --- /dev/null +++ b/app/discord/index.ts @@ -0,0 +1,124 @@ +import axios from 'axios'; +import { prisma } from "~/db.server"; +import { createUserSession } from '~/session.server'; + +export interface DiscordUser { + id: string; + username: string; + discriminator: string; + avatar: string; + bot?: boolean; + system?: boolean; + mfa_enabled?: boolean; +} + +export interface AccessTokenResponse { + access_token: string; + expires: number; + refresh_token: string; +} + +export function getAccessToken(code: string): Promise { + return new Promise( + (resolve, reject) => { + const body = new FormData(); + body.append("grant_type", "authorization_code"); + body.append("code", code); + body.append("redirect_url", process.env.DISCORD_REDIRECT_URI || ""); + + axios.post("https://discord.com/api/oauth2/token", body) + .then((res) => { + const { access_token, refresh_token, expires_in } = res.data; + + resolve({ + access_token, + refresh_token, + expires: Date.now() + expires_in * 1000 + }); + }) + .catch(reject); + } + ) +} + +export async function authorize(access_code: string): Promise { + return new Promise( + (resolve, reject) => { + const formData = new FormData(); + + formData.append("client_id", process.env.DISCORD_CLIENT_ID || ""); + formData.append("client_secret", process.env.DISCORD_CLIENT_SECRET || ""); + formData.append("grant_type", "authorization_code"); + formData.append("redirect_uri", process.env.DISCORD_REDIRECT_URI || ""); + formData.append("scope", "identify"); + formData.append("code", access_code); + + axios.post("https://discord.com/api/oauth2/token", formData) + .then((res) => { + const { access_token, refresh_token, expires_in } = res.data; + + resolve({ + access_token, + refresh_token, + expires: Date.now() + expires_in * 1000 + }); + }) + .catch(reject); + } + ) +} + +export function getDiscordUser(token: string): Promise { + return new Promise( + (resolve, reject) => { + axios.get("https://discord.com/api/users/@me", { + headers: { + Authorization: `Bearer ${token}` + } + }) + .then( + (res) => { + resolve(res.data); + } + ) + .catch(reject); + } + ) +} + +export async function discordLogin(request: Request, code: string): Promise { + return new Promise( + (resolve, reject) => { + getAccessToken(code) + .then((token) => { + getDiscordUser(token.access_token) + .then(async (user) => { + prisma.discordUser.upsert({ + create: { + discord_id: user.id, + access_token: token.access_token, + refresh_token: token.refresh_token, + expires: new Date(token.expires), + username: user.username, + discriminator: user.discriminator, + }, + update: { + discord_id: user.id, + access_token: token.access_token, + refresh_token: token.refresh_token, + expires: new Date(token.expires), + username: user.username, + discriminator: user.discriminator, + }, + where: { + discord_id: user.id + } + }) + .then(async () => { + resolve(await createUserSession({request, discord_id: user.id, redirectTo: '/'})); + }) + }) + }) + } + ) +} \ No newline at end of file diff --git a/app/models/note.server.ts b/app/models/note.server.ts deleted file mode 100644 index ba56b53..0000000 --- a/app/models/note.server.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { User, Note } from "@prisma/client"; - -import { prisma } from "~/db.server"; - -export type { Note } from "@prisma/client"; - -export function getNote({ - id, - userId, -}: Pick & { - userId: User["id"]; -}) { - return prisma.note.findFirst({ - where: { id, userId }, - }); -} - -export function getNoteListItems({ userId }: { userId: User["id"] }) { - return prisma.note.findMany({ - where: { userId }, - select: { id: true, title: true }, - orderBy: { updatedAt: "desc" }, - }); -} - -export function createNote({ - body, - title, - userId, -}: Pick & { - userId: User["id"]; -}) { - return prisma.note.create({ - data: { - title, - body, - user: { - connect: { - id: userId, - }, - }, - }, - }); -} - -export function deleteNote({ - id, - userId, -}: Pick & { userId: User["id"] }) { - return prisma.note.deleteMany({ - where: { id, userId }, - }); -} diff --git a/app/models/user.server.ts b/app/models/user.server.ts index 645da04..aa17117 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -1,59 +1,65 @@ -import type { Password, User } from "@prisma/client"; -import bcrypt from "@node-rs/bcrypt"; import { prisma } from "~/db.server"; +import type {User} from "@prisma/client"; +import { AccessTokenResponse, DiscordUser } from "~/discord/index"; +export type {User, Border} from "@prisma/client"; -export type { User } from "@prisma/client"; +export async function getUserByDiscordId(discord_id: User["discord_id"]) { + return prisma.user.findUnique({ where: { discord_id: discord_id || undefined }}); +} export async function getUserById(id: User["id"]) { return prisma.user.findUnique({ where: { id } }); } -export async function getUserByEmail(email: User["email"]) { - return prisma.user.findUnique({ where: { email } }); -} - -export async function createUser(email: User["email"], password: string) { - const hashedPassword = await bcrypt.hash(password, 10); +/// SHOULD ONLY BE USED WITH A CORRESPONDING OAUTH TOKEN +export async function createUser(discord_id: User["discord_id"]) { return prisma.user.create({ data: { - email, - password: { - create: { - hash: hashedPassword, - }, - }, + discord_id }, }); } -export async function deleteUserByEmail(email: User["email"]) { - return prisma.user.delete({ where: { email } }); +export async function createDiscordLogin(discord_id: DiscordUser["id"], token_response: AccessTokenResponse) { + return prisma.discordUser.create({ + data: { + discord_id, + access_token: token_response.access_token, + refresh_token: token_response.refresh_token, + expires: new Date(token_response.expires) + } + }) } -export async function verifyLogin( - email: User["email"], - password: Password["hash"] -) { - const userWithPassword = await prisma.user.findUnique({ - where: { email }, - include: { - password: true, - }, - }); +export type SessionInformation = { + bearer_token: string, + refresh_token: string +}; - if (!userWithPassword || !userWithPassword.password) { - return null; +export async function discordIdentify(bearer_token: string, refresh_token: string): Promise { + let user_info = await ( + await fetch("https://discord.com/api/users/@me", { + headers: { + Authorization: `Bearer ${bearer_token}` + } + }) + ).json(); + + if (!user_info["id"]) { + const form_data = new FormData(); + form_data.append("client_id", process.env.DISCORD_CLIENT_ID || ""); + form_data.append("client_secret", process.env.DISCORD_CLIENT_SECRET || ""); + form_data.append("grant_type", "refresh_token"); + form_data.append("refresh_token", refresh_token); + let refresh_info = await (await fetch("https://discord.com/api/oauth2/token", { + method: "POST", + body: form_data + })).json(); + + return refresh_info; } - const isValid = await bcrypt.verify(password, userWithPassword.password.hash); - - if (!isValid) { - return null; - } - - const { password: _password, ...userWithoutPassword } = userWithPassword; - - return userWithoutPassword; -} + return user_info; +} \ No newline at end of file diff --git a/app/root.tsx b/app/root.tsx index e38495e..3374717 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -6,11 +6,11 @@ import { Outlet, Scripts, ScrollRestoration, + useLoaderData, } from "remix"; import type { LinksFunction, MetaFunction, LoaderFunction } from "remix"; import tailwindStylesheetUrl from "./styles/tailwind.css"; -import { getUser } from "./session.server"; export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: tailwindStylesheetUrl }]; @@ -18,20 +18,10 @@ export const links: LinksFunction = () => { export const meta: MetaFunction = () => ({ charset: "utf-8", - title: "Remix Notes", + title: "Border Selector", viewport: "width=device-width,initial-scale=1", }); -type LoaderData = { - user: Awaited>; -}; - -export const loader: LoaderFunction = async ({ request }) => { - return json({ - user: await getUser(request), - }); -}; - export default function App() { return ( diff --git a/app/routes/index.tsx b/app/routes/index.tsx index 06e849b..a8ffd1c 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -1,137 +1,23 @@ -import { Link } from "remix"; +import { json, Link, LoaderFunction, useLoaderData } from "remix"; +import { getUser, getSession } from "~/session.server"; import { useOptionalUser } from "~/utils"; +export const loader: LoaderFunction = async ({ request }) => { + const user_info = await getUser(request); + return json(user_info); +} + export default function Index() { - const user = useOptionalUser(); + const discordUser = useLoaderData(); + console.log("discordUser is", discordUser); return (
-
-
-
-
- BB King playing blues on his Les Paul guitar -
-
-
-

- - Blues Stack - -

-

- Check the README.md file for instructions on how to get this - project deployed. -

-
- {user ? ( - - View Notes for {user.email} - - ) : ( -
- - Sign up - - - Log In - -
- )} -
- - Remix - -
-
-
- -
-
- {[ - { - src: "https://user-images.githubusercontent.com/1500684/157764397-ccd8ea10-b8aa-4772-a99b-35de937319e1.svg", - alt: "Fly.io", - href: "https://fly.io", - }, - { - src: "https://user-images.githubusercontent.com/1500684/158238105-e7279a0c-1640-40db-86b0-3d3a10aab824.svg", - alt: "PostgreSQL", - href: "https://www.postgresql.org/", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157764484-ad64a21a-d7fb-47e3-8669-ec046da20c1f.svg", - alt: "Prisma", - href: "https://prisma.io", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157764276-a516a239-e377-4a20-b44a-0ac7b65c8c14.svg", - alt: "Tailwind", - href: "https://tailwindcss.com", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157764454-48ac8c71-a2a9-4b5e-b19c-edef8b8953d6.svg", - alt: "Cypress", - href: "https://www.cypress.io", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157772386-75444196-0604-4340-af28-53b236faa182.svg", - alt: "MSW", - href: "https://mswjs.io", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157772447-00fccdce-9d12-46a3-8bb4-fac612cdc949.svg", - alt: "Vitest", - href: "https://vitest.dev", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157772662-92b0dd3a-453f-4d18-b8be-9fa6efde52cf.png", - alt: "Testing Library", - href: "https://testing-library.com", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157772934-ce0a943d-e9d0-40f8-97f3-f464c0811643.svg", - alt: "Prettier", - href: "https://prettier.io", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157772990-3968ff7c-b551-4c55-a25c-046a32709a8e.svg", - alt: "ESLint", - href: "https://eslint.org", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157773063-20a0ed64-b9f8-4e0b-9d1e-0b65a3d4a6db.svg", - alt: "TypeScript", - href: "https://typescriptlang.org", - }, - ].map((img) => ( - - {img.alt} - - ))} -
-
-
+

Do you love the color of the sky?

+
+ + Log in +
); } diff --git a/app/routes/join.tsx b/app/routes/join.tsx deleted file mode 100644 index 06b0c65..0000000 --- a/app/routes/join.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import * as React from "react"; -import type { ActionFunction, LoaderFunction, MetaFunction } from "remix"; -import { - Form, - Link, - redirect, - useSearchParams, - json, - useActionData, -} from "remix"; - -import { getUserId, createUserSession } from "~/session.server"; - -import { createUser, getUserByEmail } from "~/models/user.server"; -import { validateEmail } from "~/utils"; - -export const loader: LoaderFunction = async ({ request }) => { - const userId = await getUserId(request); - if (userId) return redirect("/"); - return json({}); -}; - -interface ActionData { - errors: { - email?: string; - password?: string; - }; -} - -export const action: ActionFunction = async ({ request }) => { - const formData = await request.formData(); - const email = formData.get("email"); - const password = formData.get("password"); - const redirectTo = formData.get("redirectTo"); - - if (!validateEmail(email)) { - return json( - { errors: { email: "Email is invalid" } }, - { status: 400 } - ); - } - - if (typeof password !== "string") { - return json( - { errors: { password: "Password is required" } }, - { status: 400 } - ); - } - - if (password.length < 8) { - return json( - { errors: { password: "Password is too short" } }, - { status: 400 } - ); - } - - const existingUser = await getUserByEmail(email); - if (existingUser) { - return json( - { errors: { email: "A user already exists with this email" } }, - { status: 400 } - ); - } - - const user = await createUser(email, password); - - return createUserSession({ - request, - userId: user.id, - remember: false, - redirectTo: typeof redirectTo === "string" ? redirectTo : "/", - }); -}; - -export const meta: MetaFunction = () => { - return { - title: "Sign Up", - }; -}; - -export default function Join() { - const [searchParams] = useSearchParams(); - const redirectTo = searchParams.get("redirectTo") ?? undefined; - const actionData = useActionData() as ActionData; - const emailRef = React.useRef(null); - const passwordRef = React.useRef(null); - - React.useEffect(() => { - if (actionData?.errors?.email) { - emailRef.current?.focus(); - } else if (actionData?.errors?.password) { - passwordRef.current?.focus(); - } - }, [actionData]); - - return ( -
-
-
-
- -
- - {actionData?.errors?.email && ( -
- {actionData.errors.email} -
- )} -
-
- -
- -
- - {actionData?.errors?.password && ( -
- {actionData.errors.password} -
- )} -
-
- - - -
-
- Already have an account?{" "} - - Log in - -
-
-
-
-
- ); -} diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 5ba9534..1f1b151 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -8,67 +8,20 @@ import { redirect, useSearchParams, } from "remix"; +import { discordLogin } from "~/discord"; import { createUserSession, getUserId } from "~/session.server"; -import { verifyLogin } from "~/models/user.server"; -import { validateEmail } from "~/utils"; export const loader: LoaderFunction = async ({ request }) => { const userId = await getUserId(request); if (userId) return redirect("/"); - return json({}); -}; -interface ActionData { - errors?: { - email?: string; - password?: string; - }; -} + const url = new URL(await request.url); + const accessCode = url.searchParams.get("code") || ""; -export const action: ActionFunction = async ({ request }) => { - const formData = await request.formData(); - const email = formData.get("email"); - const password = formData.get("password"); - const redirectTo = formData.get("redirectTo"); - const remember = formData.get("remember"); - - if (!validateEmail(email)) { - return json( - { errors: { email: "Email is invalid" } }, - { status: 400 } - ); - } - - if (typeof password !== "string") { - return json( - { errors: { password: "Password is required" } }, - { status: 400 } - ); - } - - if (password.length < 8) { - return json( - { errors: { password: "Password is too short" } }, - { status: 400 } - ); - } - - const user = await verifyLogin(email, password); - - if (!user) { - return json( - { errors: { email: "Invalid email or password" } }, - { status: 400 } - ); - } - - return createUserSession({ - request, - userId: user.id, - remember: remember === "on" ? true : false, - redirectTo: typeof redirectTo === "string" ? redirectTo : "/notes", - }); + const response = await discordLogin(request, accessCode); + console.log(response); + return "" }; export const meta: MetaFunction = () => { @@ -78,115 +31,9 @@ export const meta: MetaFunction = () => { }; export default function LoginPage() { - const [searchParams] = useSearchParams(); - const redirectTo = searchParams.get("redirectTo") || "/notes"; - const actionData = useActionData() as ActionData; - const emailRef = React.useRef(null); - const passwordRef = React.useRef(null); - - React.useEffect(() => { - if (actionData?.errors?.email) { - emailRef.current?.focus(); - } else if (actionData?.errors?.password) { - passwordRef.current?.focus(); - } - }, [actionData]); - return (
-
-
-
- -
- - {actionData?.errors?.email && ( -
- {actionData.errors.email} -
- )} -
-
- -
- -
- - {actionData?.errors?.password && ( -
- {actionData.errors.password} -
- )} -
-
- - - -
-
- - -
-
- Don't have an account?{" "} - - Sign up - -
-
-
-
+

Error logging in.

- ); + ) } diff --git a/app/routes/loginstart.tsx b/app/routes/loginstart.tsx new file mode 100644 index 0000000..6e619fb --- /dev/null +++ b/app/routes/loginstart.tsx @@ -0,0 +1,25 @@ +import type { LoaderFunction, MetaFunction } from "remix"; +import { + json, + redirect, +} from "remix"; +import { getUserId } from "~/session.server"; + +export const loader: LoaderFunction = async ({ request }) => { + const userId = await getUserId(request); + if (userId) return redirect("/"); + const client_id = process.env.DISCORD_CLIENT_ID || ""; + const redirect_uri = process.env.DISCORD_REDIRECT_URI || ""; + return redirect(`https://discord.com/api/oauth2/authorize?client_id=${client_id}` + + `&response_type=code&redirect_uri=${encodeURIComponent(redirect_uri)}&scope=identify`); +}; + +export const meta: MetaFunction = () => { + return { + title: "Login", + }; +}; + +export default function LoginPage() { + return
+} diff --git a/app/routes/notes.tsx b/app/routes/notes.tsx deleted file mode 100644 index 8a496a9..0000000 --- a/app/routes/notes.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Form, json, useLoaderData, Outlet, Link, NavLink } from "remix"; -import type { LoaderFunction } from "remix"; - -import { requireUserId } from "~/session.server"; -import { useUser } from "~/utils"; -import { getNoteListItems } from "~/models/note.server"; - -type LoaderData = { - noteListItems: Awaited>; -}; - -export const loader: LoaderFunction = async ({ request }) => { - const userId = await requireUserId(request); - const noteListItems = await getNoteListItems({ userId }); - return json({ noteListItems }); -}; - -export default function NotesPage() { - const data = useLoaderData() as LoaderData; - const user = useUser(); - - return ( -
-
-

- Notes -

-

{user.email}

-
- -
-
- -
-
- - + New Note - - -
- - {data.noteListItems.length === 0 ? ( -

No notes yet

- ) : ( -
    - {data.noteListItems.map((note) => ( -
  1. - - `block border-b p-4 text-xl ${isActive ? "bg-white" : ""}` - } - to={note.id} - > - 📝 {note.title} - -
  2. - ))} -
- )} -
- -
- -
-
-
- ); -} diff --git a/app/routes/notes/$noteId.tsx b/app/routes/notes/$noteId.tsx deleted file mode 100644 index 10b4e65..0000000 --- a/app/routes/notes/$noteId.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import type { LoaderFunction, ActionFunction } from "remix"; -import { redirect } from "remix"; -import { json, useLoaderData, useCatch, Form } from "remix"; -import invariant from "tiny-invariant"; -import type { Note } from "~/models/note.server"; -import { deleteNote } from "~/models/note.server"; -import { getNote } from "~/models/note.server"; -import { requireUserId } from "~/session.server"; - -type LoaderData = { - note: Note; -}; - -export const loader: LoaderFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - invariant(params.noteId, "noteId not found"); - - const note = await getNote({ userId, id: params.noteId }); - if (!note) { - throw new Response("Not Found", { status: 404 }); - } - return json({ note }); -}; - -export const action: ActionFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - invariant(params.noteId, "noteId not found"); - - await deleteNote({ userId, id: params.noteId }); - - return redirect("/notes"); -}; - -export default function NoteDetailsPage() { - const data = useLoaderData() as LoaderData; - - return ( -
-

{data.note.title}

-

{data.note.body}

-
-
- -
-
- ); -} - -export function ErrorBoundary({ error }: { error: Error }) { - console.error(error); - - return
An unexpected error occurred: {error.message}
; -} - -export function CatchBoundary() { - const caught = useCatch(); - - if (caught.status === 404) { - return
Note not found
; - } - - throw new Error(`Unexpected caught response with status: ${caught.status}`); -} diff --git a/app/routes/notes/index.tsx b/app/routes/notes/index.tsx deleted file mode 100644 index 30df34b..0000000 --- a/app/routes/notes/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Link } from "remix"; - -export default function NoteIndexPage() { - return ( -

- No note selected. Select a note on the left, or{" "} - - create a new note. - -

- ); -} diff --git a/app/routes/notes/new.tsx b/app/routes/notes/new.tsx deleted file mode 100644 index 3c6fee6..0000000 --- a/app/routes/notes/new.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import * as React from "react"; -import { Form, json, redirect, useActionData } from "remix"; -import type { ActionFunction } from "remix"; -import Alert from "@reach/alert"; - -import { createNote } from "~/models/note.server"; -import { requireUserId } from "~/session.server"; - -type ActionData = { - errors?: { - title?: string; - body?: string; - }; -}; - -export const action: ActionFunction = async ({ request }) => { - const userId = await requireUserId(request); - - const formData = await request.formData(); - const title = formData.get("title"); - const body = formData.get("body"); - - if (typeof title !== "string" || title.length === 0) { - return json( - { errors: { title: "Title is required" } }, - { status: 400 } - ); - } - - if (typeof body !== "string" || body.length === 0) { - return json( - { errors: { body: "Body is required" } }, - { status: 400 } - ); - } - - const note = await createNote({ title, body, userId }); - - return redirect(`/notes/${note.id}`); -}; - -export default function NewNotePage() { - const actionData = useActionData() as ActionData; - const titleRef = React.useRef(null); - const bodyRef = React.useRef(null); - - React.useEffect(() => { - if (actionData?.errors?.title) { - titleRef.current?.focus(); - } else if (actionData?.errors?.body) { - bodyRef.current?.focus(); - } - }, [actionData]); - - return ( -
-
- - {actionData?.errors?.title && ( - - {actionData.errors.title} - - )} -
- -
-