help me
This commit is contained in:
		
							parent
							
								
									52a0ba1b3b
								
							
						
					
					
						commit
						83bc981152
					
				
					 23 changed files with 9034 additions and 927 deletions
				
			
		|  | @ -1,2 +0,0 @@ | ||||||
| DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" |  | ||||||
| SESSION_SECRET="super-duper-s3cret" |  | ||||||
							
								
								
									
										124
									
								
								app/discord/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								app/discord/index.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<AccessTokenResponse> { | ||||||
|  |     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<AccessTokenResponse | undefined> { | ||||||
|  |     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<DiscordUser> { | ||||||
|  |     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<Response> { | ||||||
|  |     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: '/'})); | ||||||
|  |                             }) | ||||||
|  |                         }) | ||||||
|  |                 }) | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  | @ -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<Note, "id"> & { |  | ||||||
|   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<Note, "body" | "title"> & { |  | ||||||
|   userId: User["id"]; |  | ||||||
| }) { |  | ||||||
|   return prisma.note.create({ |  | ||||||
|     data: { |  | ||||||
|       title, |  | ||||||
|       body, |  | ||||||
|       user: { |  | ||||||
|         connect: { |  | ||||||
|           id: userId, |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|     }, |  | ||||||
|   }); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export function deleteNote({ |  | ||||||
|   id, |  | ||||||
|   userId, |  | ||||||
| }: Pick<Note, "id"> & { userId: User["id"] }) { |  | ||||||
|   return prisma.note.deleteMany({ |  | ||||||
|     where: { id, userId }, |  | ||||||
|   }); |  | ||||||
| } |  | ||||||
|  | @ -1,59 +1,65 @@ | ||||||
| import type { Password, User } from "@prisma/client"; |  | ||||||
| import bcrypt from "@node-rs/bcrypt"; |  | ||||||
| 
 | 
 | ||||||
| import { prisma } from "~/db.server"; | 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"]) { | export async function getUserById(id: User["id"]) { | ||||||
|   return prisma.user.findUnique({ where: { id } }); |   return prisma.user.findUnique({ where: { id } }); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function getUserByEmail(email: User["email"]) { | /// SHOULD ONLY BE USED WITH A CORRESPONDING OAUTH TOKEN
 | ||||||
|   return prisma.user.findUnique({ where: { email } }); | export async function createUser(discord_id: User["discord_id"]) { | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export async function createUser(email: User["email"], password: string) { |  | ||||||
|   const hashedPassword = await bcrypt.hash(password, 10); |  | ||||||
| 
 | 
 | ||||||
|   return prisma.user.create({ |   return prisma.user.create({ | ||||||
|     data: { |     data: { | ||||||
|       email, |       discord_id | ||||||
|       password: { |  | ||||||
|         create: { |  | ||||||
|           hash: hashedPassword, |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function deleteUserByEmail(email: User["email"]) { | export async function createDiscordLogin(discord_id: DiscordUser["id"], token_response: AccessTokenResponse) { | ||||||
|   return prisma.user.delete({ where: { email } }); |   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( | export type SessionInformation = { | ||||||
|   email: User["email"], |   bearer_token: string, | ||||||
|   password: Password["hash"] |   refresh_token: string | ||||||
| ) { | }; | ||||||
|   const userWithPassword = await prisma.user.findUnique({ |  | ||||||
|     where: { email }, |  | ||||||
|     include: { |  | ||||||
|       password: true, |  | ||||||
|     }, |  | ||||||
|   }); |  | ||||||
| 
 | 
 | ||||||
|   if (!userWithPassword || !userWithPassword.password) { | export async function discordIdentify(bearer_token: string, refresh_token: string): Promise<DiscordUser | SessionInformation | undefined> { | ||||||
|     return null; |   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); |   return user_info; | ||||||
| 
 |  | ||||||
|   if (!isValid) { |  | ||||||
|     return null; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const { password: _password, ...userWithoutPassword } = userWithPassword; |  | ||||||
| 
 |  | ||||||
|   return userWithoutPassword; |  | ||||||
| } | } | ||||||
							
								
								
									
										14
									
								
								app/root.tsx
									
										
									
									
									
								
							
							
						
						
									
										14
									
								
								app/root.tsx
									
										
									
									
									
								
							|  | @ -6,11 +6,11 @@ import { | ||||||
|   Outlet, |   Outlet, | ||||||
|   Scripts, |   Scripts, | ||||||
|   ScrollRestoration, |   ScrollRestoration, | ||||||
|  |   useLoaderData, | ||||||
| } from "remix"; | } from "remix"; | ||||||
| import type { LinksFunction, MetaFunction, LoaderFunction } from "remix"; | import type { LinksFunction, MetaFunction, LoaderFunction } from "remix"; | ||||||
| 
 | 
 | ||||||
| import tailwindStylesheetUrl from "./styles/tailwind.css"; | import tailwindStylesheetUrl from "./styles/tailwind.css"; | ||||||
| import { getUser } from "./session.server"; |  | ||||||
| 
 | 
 | ||||||
| export const links: LinksFunction = () => { | export const links: LinksFunction = () => { | ||||||
|   return [{ rel: "stylesheet", href: tailwindStylesheetUrl }]; |   return [{ rel: "stylesheet", href: tailwindStylesheetUrl }]; | ||||||
|  | @ -18,20 +18,10 @@ export const links: LinksFunction = () => { | ||||||
| 
 | 
 | ||||||
| export const meta: MetaFunction = () => ({ | export const meta: MetaFunction = () => ({ | ||||||
|   charset: "utf-8", |   charset: "utf-8", | ||||||
|   title: "Remix Notes", |   title: "Border Selector", | ||||||
|   viewport: "width=device-width,initial-scale=1", |   viewport: "width=device-width,initial-scale=1", | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| type LoaderData = { |  | ||||||
|   user: Awaited<ReturnType<typeof getUser>>; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const loader: LoaderFunction = async ({ request }) => { |  | ||||||
|   return json<LoaderData>({ |  | ||||||
|     user: await getUser(request), |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export default function App() { | export default function App() { | ||||||
|   return ( |   return ( | ||||||
|     <html lang="en" className="h-full"> |     <html lang="en" className="h-full"> | ||||||
|  |  | ||||||
|  | @ -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"; | import { useOptionalUser } from "~/utils"; | ||||||
| 
 | 
 | ||||||
|  | export const loader: LoaderFunction = async ({ request }) => { | ||||||
|  |   const user_info = await getUser(request); | ||||||
|  |   return json(user_info); | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export default function Index() { | export default function Index() { | ||||||
|   const user = useOptionalUser(); |   const discordUser = useLoaderData(); | ||||||
|  |   console.log("discordUser is", discordUser); | ||||||
|   return ( |   return ( | ||||||
|     <main className="relative min-h-screen bg-white sm:flex sm:items-center sm:justify-center"> |     <main className="relative min-h-screen bg-white sm:flex sm:items-center sm:justify-center"> | ||||||
|       <div className="relative sm:pb-16 sm:pt-8"> |       <h1>Do you love the color of the sky?</h1> | ||||||
|         <div className="mx-auto max-w-7xl sm:px-6 lg:px-8"> |       <br /> | ||||||
|           <div className="relative shadow-xl sm:overflow-hidden sm:rounded-2xl"> |       <Link | ||||||
|             <div className="absolute inset-0"> |       to="/loginstart"> | ||||||
|               <img |       Log in | ||||||
|                 className="h-full w-full object-cover" |       </Link> | ||||||
|                 src="https://user-images.githubusercontent.com/1500684/158276320-c46b661b-8eff-4a4d-82c6-cf296c987a12.jpg" |  | ||||||
|                 alt="BB King playing blues on his Les Paul guitar" |  | ||||||
|               /> |  | ||||||
|               <div className="absolute inset-0 bg-[color:rgba(27,167,254,0.5)] mix-blend-multiply" /> |  | ||||||
|             </div> |  | ||||||
|             <div className="lg:pb-18 relative px-4 pt-16 pb-8 sm:px-6 sm:pt-24 sm:pb-14 lg:px-8 lg:pt-32"> |  | ||||||
|               <h1 className="text-center text-6xl font-extrabold tracking-tight sm:text-8xl lg:text-9xl"> |  | ||||||
|                 <span className="block uppercase text-blue-500 drop-shadow-md"> |  | ||||||
|                   Blues Stack |  | ||||||
|                 </span> |  | ||||||
|               </h1> |  | ||||||
|               <p className="mx-auto mt-6 max-w-lg text-center text-xl text-white sm:max-w-3xl"> |  | ||||||
|                 Check the README.md file for instructions on how to get this |  | ||||||
|                 project deployed. |  | ||||||
|               </p> |  | ||||||
|               <div className="mx-auto mt-10 max-w-sm sm:flex sm:max-w-none sm:justify-center"> |  | ||||||
|                 {user ? ( |  | ||||||
|                   <Link |  | ||||||
|                     to="/notes" |  | ||||||
|                     className="flex items-center justify-center rounded-md border border-transparent bg-white px-4 py-3 text-base font-medium text-blue-700 shadow-sm hover:bg-blue-50 sm:px-8" |  | ||||||
|                   > |  | ||||||
|                     View Notes for {user.email} |  | ||||||
|                   </Link> |  | ||||||
|                 ) : ( |  | ||||||
|                   <div className="space-y-4 sm:mx-auto sm:inline-grid sm:grid-cols-2 sm:gap-5 sm:space-y-0"> |  | ||||||
|                     <Link |  | ||||||
|                       to="/join" |  | ||||||
|                       className="flex items-center justify-center rounded-md border border-transparent bg-white px-4 py-3 text-base font-medium text-blue-700 shadow-sm hover:bg-blue-50 sm:px-8" |  | ||||||
|                     > |  | ||||||
|                       Sign up |  | ||||||
|                     </Link> |  | ||||||
|                     <Link |  | ||||||
|                       to="/login" |  | ||||||
|                       className="flex items-center justify-center rounded-md bg-blue-500 px-4 py-3 font-medium text-white hover:bg-blue-600  " |  | ||||||
|                     > |  | ||||||
|                       Log In |  | ||||||
|                     </Link> |  | ||||||
|                   </div> |  | ||||||
|                 )} |  | ||||||
|               </div> |  | ||||||
|               <a href="https://remix.run"> |  | ||||||
|                 <img |  | ||||||
|                   src="https://user-images.githubusercontent.com/1500684/158298926-e45dafff-3544-4b69-96d6-d3bcc33fc76a.svg" |  | ||||||
|                   alt="Remix" |  | ||||||
|                   className="mx-auto mt-16 w-full max-w-[12rem] md:max-w-[16rem]" |  | ||||||
|                 /> |  | ||||||
|               </a> |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
| 
 |  | ||||||
|         <div className="mx-auto max-w-7xl py-2 px-4 sm:px-6 lg:px-8"> |  | ||||||
|           <div className="mt-6 flex flex-wrap justify-center gap-8"> |  | ||||||
|             {[ |  | ||||||
|               { |  | ||||||
|                 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) => ( |  | ||||||
|               <a |  | ||||||
|                 key={img.href} |  | ||||||
|                 href={img.href} |  | ||||||
|                 className="flex h-16 w-32 justify-center p-1 grayscale transition hover:grayscale-0 focus:grayscale-0" |  | ||||||
|               > |  | ||||||
|                 <img alt={img.alt} src={img.src} /> |  | ||||||
|               </a> |  | ||||||
|             ))} |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     </main> |     </main> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -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<ActionData>( |  | ||||||
|       { errors: { email: "Email is invalid" } }, |  | ||||||
|       { status: 400 } |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   if (typeof password !== "string") { |  | ||||||
|     return json<ActionData>( |  | ||||||
|       { errors: { password: "Password is required" } }, |  | ||||||
|       { status: 400 } |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   if (password.length < 8) { |  | ||||||
|     return json<ActionData>( |  | ||||||
|       { errors: { password: "Password is too short" } }, |  | ||||||
|       { status: 400 } |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const existingUser = await getUserByEmail(email); |  | ||||||
|   if (existingUser) { |  | ||||||
|     return json<ActionData>( |  | ||||||
|       { 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<HTMLInputElement>(null); |  | ||||||
|   const passwordRef = React.useRef<HTMLInputElement>(null); |  | ||||||
| 
 |  | ||||||
|   React.useEffect(() => { |  | ||||||
|     if (actionData?.errors?.email) { |  | ||||||
|       emailRef.current?.focus(); |  | ||||||
|     } else if (actionData?.errors?.password) { |  | ||||||
|       passwordRef.current?.focus(); |  | ||||||
|     } |  | ||||||
|   }, [actionData]); |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <div className="flex min-h-full flex-col justify-center"> |  | ||||||
|       <div className="mx-auto w-full max-w-md px-8"> |  | ||||||
|         <Form method="post" className="space-y-6" noValidate> |  | ||||||
|           <div> |  | ||||||
|             <label |  | ||||||
|               htmlFor="email" |  | ||||||
|               className="block text-sm font-medium text-gray-700" |  | ||||||
|             > |  | ||||||
|               Email address |  | ||||||
|             </label> |  | ||||||
|             <div className="mt-1"> |  | ||||||
|               <input |  | ||||||
|                 ref={emailRef} |  | ||||||
|                 id="email" |  | ||||||
|                 required |  | ||||||
|                 autoFocus={true} |  | ||||||
|                 name="email" |  | ||||||
|                 type="email" |  | ||||||
|                 autoComplete="email" |  | ||||||
|                 aria-invalid={actionData?.errors?.email ? true : undefined} |  | ||||||
|                 aria-describedby="email-error" |  | ||||||
|                 className="w-full rounded border border-gray-500 px-2 py-1 text-lg" |  | ||||||
|               /> |  | ||||||
|               {actionData?.errors?.email && ( |  | ||||||
|                 <div className="pt-1 text-red-700" id="email-error"> |  | ||||||
|                   {actionData.errors.email} |  | ||||||
|                 </div> |  | ||||||
|               )} |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
| 
 |  | ||||||
|           <div> |  | ||||||
|             <label |  | ||||||
|               htmlFor="password" |  | ||||||
|               className="block text-sm font-medium text-gray-700" |  | ||||||
|             > |  | ||||||
|               Password |  | ||||||
|             </label> |  | ||||||
|             <div className="mt-1"> |  | ||||||
|               <input |  | ||||||
|                 id="password" |  | ||||||
|                 ref={passwordRef} |  | ||||||
|                 name="password" |  | ||||||
|                 type="password" |  | ||||||
|                 autoComplete="new-password" |  | ||||||
|                 aria-invalid={actionData?.errors?.password ? true : undefined} |  | ||||||
|                 aria-describedby="password-error" |  | ||||||
|                 className="w-full rounded border border-gray-500 px-2 py-1 text-lg" |  | ||||||
|               /> |  | ||||||
|               {actionData?.errors?.password && ( |  | ||||||
|                 <div className="pt-1 text-red-700" id="password-error"> |  | ||||||
|                   {actionData.errors.password} |  | ||||||
|                 </div> |  | ||||||
|               )} |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
| 
 |  | ||||||
|           <input type="hidden" name="redirectTo" value={redirectTo} /> |  | ||||||
|           <button |  | ||||||
|             type="submit" |  | ||||||
|             className="w-full rounded bg-blue-500  py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400" |  | ||||||
|           > |  | ||||||
|             Create Account |  | ||||||
|           </button> |  | ||||||
|           <div className="flex items-center justify-center"> |  | ||||||
|             <div className="text-center text-sm text-gray-500"> |  | ||||||
|               Already have an account?{" "} |  | ||||||
|               <Link |  | ||||||
|                 className="text-blue-500 underline" |  | ||||||
|                 to={{ |  | ||||||
|                   pathname: "/login", |  | ||||||
|                   search: searchParams.toString(), |  | ||||||
|                 }} |  | ||||||
|               > |  | ||||||
|                 Log in |  | ||||||
|               </Link> |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
|         </Form> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
|  | @ -8,67 +8,20 @@ import { | ||||||
|   redirect, |   redirect, | ||||||
|   useSearchParams, |   useSearchParams, | ||||||
| } from "remix"; | } from "remix"; | ||||||
|  | import { discordLogin } from "~/discord"; | ||||||
| 
 | 
 | ||||||
| import { createUserSession, getUserId } from "~/session.server"; | import { createUserSession, getUserId } from "~/session.server"; | ||||||
| import { verifyLogin } from "~/models/user.server"; |  | ||||||
| import { validateEmail } from "~/utils"; |  | ||||||
| 
 | 
 | ||||||
| export const loader: LoaderFunction = async ({ request }) => { | export const loader: LoaderFunction = async ({ request }) => { | ||||||
|   const userId = await getUserId(request); |   const userId = await getUserId(request); | ||||||
|   if (userId) return redirect("/"); |   if (userId) return redirect("/"); | ||||||
|   return json({}); |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| interface ActionData { |   const url = new URL(await request.url); | ||||||
|   errors?: { |   const accessCode = url.searchParams.get("code") || ""; | ||||||
|     email?: string; |  | ||||||
|     password?: string; |  | ||||||
|   }; |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| export const action: ActionFunction = async ({ request }) => { |   const response = await discordLogin(request, accessCode); | ||||||
|   const formData = await request.formData(); |   console.log(response); | ||||||
|   const email = formData.get("email"); |   return "" | ||||||
|   const password = formData.get("password"); |  | ||||||
|   const redirectTo = formData.get("redirectTo"); |  | ||||||
|   const remember = formData.get("remember"); |  | ||||||
| 
 |  | ||||||
|   if (!validateEmail(email)) { |  | ||||||
|     return json<ActionData>( |  | ||||||
|       { errors: { email: "Email is invalid" } }, |  | ||||||
|       { status: 400 } |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   if (typeof password !== "string") { |  | ||||||
|     return json<ActionData>( |  | ||||||
|       { errors: { password: "Password is required" } }, |  | ||||||
|       { status: 400 } |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   if (password.length < 8) { |  | ||||||
|     return json<ActionData>( |  | ||||||
|       { errors: { password: "Password is too short" } }, |  | ||||||
|       { status: 400 } |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const user = await verifyLogin(email, password); |  | ||||||
| 
 |  | ||||||
|   if (!user) { |  | ||||||
|     return json<ActionData>( |  | ||||||
|       { 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", |  | ||||||
|   }); |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const meta: MetaFunction = () => { | export const meta: MetaFunction = () => { | ||||||
|  | @ -78,115 +31,9 @@ export const meta: MetaFunction = () => { | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default function LoginPage() { | export default function LoginPage() { | ||||||
|   const [searchParams] = useSearchParams(); |  | ||||||
|   const redirectTo = searchParams.get("redirectTo") || "/notes"; |  | ||||||
|   const actionData = useActionData() as ActionData; |  | ||||||
|   const emailRef = React.useRef<HTMLInputElement>(null); |  | ||||||
|   const passwordRef = React.useRef<HTMLInputElement>(null); |  | ||||||
| 
 |  | ||||||
|   React.useEffect(() => { |  | ||||||
|     if (actionData?.errors?.email) { |  | ||||||
|       emailRef.current?.focus(); |  | ||||||
|     } else if (actionData?.errors?.password) { |  | ||||||
|       passwordRef.current?.focus(); |  | ||||||
|     } |  | ||||||
|   }, [actionData]); |  | ||||||
| 
 |  | ||||||
|   return ( |   return ( | ||||||
|     <div className="flex min-h-full flex-col justify-center"> |     <div className="flex min-h-full flex-col justify-center"> | ||||||
|       <div className="mx-auto w-full max-w-md px-8"> |       <p>Error logging in.</p>   | ||||||
|         <Form method="post" className="space-y-6" noValidate> |  | ||||||
|           <div> |  | ||||||
|             <label |  | ||||||
|               htmlFor="email" |  | ||||||
|               className="block text-sm font-medium text-gray-700" |  | ||||||
|             > |  | ||||||
|               Email address |  | ||||||
|             </label> |  | ||||||
|             <div className="mt-1"> |  | ||||||
|               <input |  | ||||||
|                 ref={emailRef} |  | ||||||
|                 id="email" |  | ||||||
|                 required |  | ||||||
|                 autoFocus={true} |  | ||||||
|                 name="email" |  | ||||||
|                 type="email" |  | ||||||
|                 autoComplete="email" |  | ||||||
|                 aria-invalid={actionData?.errors?.email ? true : undefined} |  | ||||||
|                 aria-describedby="email-error" |  | ||||||
|                 className="w-full rounded border border-gray-500 px-2 py-1 text-lg" |  | ||||||
|               /> |  | ||||||
|               {actionData?.errors?.email && ( |  | ||||||
|                 <div className="pt-1 text-red-700" id="email-error"> |  | ||||||
|                   {actionData.errors.email} |  | ||||||
|                 </div> |  | ||||||
|               )} |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
| 
 |  | ||||||
|           <div> |  | ||||||
|             <label |  | ||||||
|               htmlFor="password" |  | ||||||
|               className="block text-sm font-medium text-gray-700" |  | ||||||
|             > |  | ||||||
|               Password |  | ||||||
|             </label> |  | ||||||
|             <div className="mt-1"> |  | ||||||
|               <input |  | ||||||
|                 id="password" |  | ||||||
|                 ref={passwordRef} |  | ||||||
|                 name="password" |  | ||||||
|                 type="password" |  | ||||||
|                 autoComplete="new-password" |  | ||||||
|                 aria-invalid={actionData?.errors?.password ? true : undefined} |  | ||||||
|                 aria-describedby="password-error" |  | ||||||
|                 className="w-full rounded border border-gray-500 px-2 py-1 text-lg" |  | ||||||
|               /> |  | ||||||
|               {actionData?.errors?.password && ( |  | ||||||
|                 <div className="pt-1 text-red-700" id="password-error"> |  | ||||||
|                   {actionData.errors.password} |  | ||||||
|                 </div> |  | ||||||
|               )} |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
| 
 |  | ||||||
|           <input type="hidden" name="redirectTo" value={redirectTo} /> |  | ||||||
|           <button |  | ||||||
|             type="submit" |  | ||||||
|             className="w-full rounded bg-blue-500  py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400" |  | ||||||
|           > |  | ||||||
|             Log in |  | ||||||
|           </button> |  | ||||||
|           <div className="flex items-center justify-between"> |  | ||||||
|             <div className="flex items-center"> |  | ||||||
|               <input |  | ||||||
|                 id="remember" |  | ||||||
|                 name="remember" |  | ||||||
|                 type="checkbox" |  | ||||||
|                 className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" |  | ||||||
|               /> |  | ||||||
|               <label |  | ||||||
|                 htmlFor="remember" |  | ||||||
|                 className="ml-2 block text-sm text-gray-900" |  | ||||||
|               > |  | ||||||
|                 Remember me |  | ||||||
|               </label> |  | ||||||
|             </div> |  | ||||||
|             <div className="text-center text-sm text-gray-500"> |  | ||||||
|               Don't have an account?{" "} |  | ||||||
|               <Link |  | ||||||
|                 className="text-blue-500 underline" |  | ||||||
|                 to={{ |  | ||||||
|                   pathname: "/join", |  | ||||||
|                   search: searchParams.toString(), |  | ||||||
|                 }} |  | ||||||
|               > |  | ||||||
|                 Sign up |  | ||||||
|               </Link> |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
|         </Form> |  | ||||||
|       </div> |  | ||||||
|     </div> |     </div> | ||||||
|   ); |   ) | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										25
									
								
								app/routes/loginstart.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								app/routes/loginstart.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 <div></div> | ||||||
|  | } | ||||||
|  | @ -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<ReturnType<typeof getNoteListItems>>; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const loader: LoaderFunction = async ({ request }) => { |  | ||||||
|   const userId = await requireUserId(request); |  | ||||||
|   const noteListItems = await getNoteListItems({ userId }); |  | ||||||
|   return json<LoaderData>({ noteListItems }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export default function NotesPage() { |  | ||||||
|   const data = useLoaderData() as LoaderData; |  | ||||||
|   const user = useUser(); |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <div className="flex h-full min-h-screen flex-col"> |  | ||||||
|       <header className="flex items-center justify-between bg-slate-800 p-4 text-white"> |  | ||||||
|         <h1 className="text-3xl font-bold"> |  | ||||||
|           <Link to=".">Notes</Link> |  | ||||||
|         </h1> |  | ||||||
|         <p>{user.email}</p> |  | ||||||
|         <Form action="/logout" method="post"> |  | ||||||
|           <button |  | ||||||
|             type="submit" |  | ||||||
|             className="rounded bg-slate-600 py-2 px-4 text-blue-100 hover:bg-blue-500 active:bg-blue-600" |  | ||||||
|           > |  | ||||||
|             Logout |  | ||||||
|           </button> |  | ||||||
|         </Form> |  | ||||||
|       </header> |  | ||||||
| 
 |  | ||||||
|       <main className="flex h-full bg-white"> |  | ||||||
|         <div className="h-full w-80 border-r bg-gray-50"> |  | ||||||
|           <Link to="new" className="block p-4 text-xl text-blue-500"> |  | ||||||
|             + New Note |  | ||||||
|           </Link> |  | ||||||
| 
 |  | ||||||
|           <hr /> |  | ||||||
| 
 |  | ||||||
|           {data.noteListItems.length === 0 ? ( |  | ||||||
|             <p className="p-4">No notes yet</p> |  | ||||||
|           ) : ( |  | ||||||
|             <ol> |  | ||||||
|               {data.noteListItems.map((note) => ( |  | ||||||
|                 <li key={note.id}> |  | ||||||
|                   <NavLink |  | ||||||
|                     className={({ isActive }) => |  | ||||||
|                       `block border-b p-4 text-xl ${isActive ? "bg-white" : ""}` |  | ||||||
|                     } |  | ||||||
|                     to={note.id} |  | ||||||
|                   > |  | ||||||
|                     📝 {note.title} |  | ||||||
|                   </NavLink> |  | ||||||
|                 </li> |  | ||||||
|               ))} |  | ||||||
|             </ol> |  | ||||||
|           )} |  | ||||||
|         </div> |  | ||||||
| 
 |  | ||||||
|         <div className="flex-1 p-6"> |  | ||||||
|           <Outlet /> |  | ||||||
|         </div> |  | ||||||
|       </main> |  | ||||||
|     </div> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
|  | @ -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<LoaderData>({ 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 ( |  | ||||||
|     <div> |  | ||||||
|       <h3 className="text-2xl font-bold">{data.note.title}</h3> |  | ||||||
|       <p className="py-6">{data.note.body}</p> |  | ||||||
|       <hr className="my-4" /> |  | ||||||
|       <Form method="post"> |  | ||||||
|         <button |  | ||||||
|           type="submit" |  | ||||||
|           className="rounded bg-blue-500  py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400" |  | ||||||
|         > |  | ||||||
|           Delete |  | ||||||
|         </button> |  | ||||||
|       </Form> |  | ||||||
|     </div> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export function ErrorBoundary({ error }: { error: Error }) { |  | ||||||
|   console.error(error); |  | ||||||
| 
 |  | ||||||
|   return <div>An unexpected error occurred: {error.message}</div>; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export function CatchBoundary() { |  | ||||||
|   const caught = useCatch(); |  | ||||||
| 
 |  | ||||||
|   if (caught.status === 404) { |  | ||||||
|     return <div>Note not found</div>; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   throw new Error(`Unexpected caught response with status: ${caught.status}`); |  | ||||||
| } |  | ||||||
|  | @ -1,12 +0,0 @@ | ||||||
| import { Link } from "remix"; |  | ||||||
| 
 |  | ||||||
| export default function NoteIndexPage() { |  | ||||||
|   return ( |  | ||||||
|     <p> |  | ||||||
|       No note selected. Select a note on the left, or{" "} |  | ||||||
|       <Link to="new" className="text-blue-500 underline"> |  | ||||||
|         create a new note. |  | ||||||
|       </Link> |  | ||||||
|     </p> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
|  | @ -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<ActionData>( |  | ||||||
|       { errors: { title: "Title is required" } }, |  | ||||||
|       { status: 400 } |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   if (typeof body !== "string" || body.length === 0) { |  | ||||||
|     return json<ActionData>( |  | ||||||
|       { 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<HTMLInputElement>(null); |  | ||||||
|   const bodyRef = React.useRef<HTMLTextAreaElement>(null); |  | ||||||
| 
 |  | ||||||
|   React.useEffect(() => { |  | ||||||
|     if (actionData?.errors?.title) { |  | ||||||
|       titleRef.current?.focus(); |  | ||||||
|     } else if (actionData?.errors?.body) { |  | ||||||
|       bodyRef.current?.focus(); |  | ||||||
|     } |  | ||||||
|   }, [actionData]); |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <Form |  | ||||||
|       method="post" |  | ||||||
|       style={{ |  | ||||||
|         display: "flex", |  | ||||||
|         flexDirection: "column", |  | ||||||
|         gap: 8, |  | ||||||
|         width: "100%", |  | ||||||
|       }} |  | ||||||
|     > |  | ||||||
|       <div> |  | ||||||
|         <label className="flex w-full flex-col gap-1"> |  | ||||||
|           <span>Title: </span> |  | ||||||
|           <input |  | ||||||
|             ref={titleRef} |  | ||||||
|             name="title" |  | ||||||
|             className="flex-1 rounded-md border-2 border-blue-500 px-3 text-lg leading-loose" |  | ||||||
|             aria-invalid={actionData?.errors?.title ? true : undefined} |  | ||||||
|             aria-errormessage={ |  | ||||||
|               actionData?.errors?.title ? "title-error" : undefined |  | ||||||
|             } |  | ||||||
|           /> |  | ||||||
|         </label> |  | ||||||
|         {actionData?.errors?.title && ( |  | ||||||
|           <Alert className="pt-1 text-red-700" id="title=error"> |  | ||||||
|             {actionData.errors.title} |  | ||||||
|           </Alert> |  | ||||||
|         )} |  | ||||||
|       </div> |  | ||||||
| 
 |  | ||||||
|       <div> |  | ||||||
|         <label className="flex w-full flex-col gap-1"> |  | ||||||
|           <span>Body: </span> |  | ||||||
|           <textarea |  | ||||||
|             ref={bodyRef} |  | ||||||
|             name="body" |  | ||||||
|             rows={8} |  | ||||||
|             className="w-full flex-1 rounded-md border-2 border-blue-500 py-2 px-3 text-lg leading-6" |  | ||||||
|             aria-invalid={actionData?.errors?.body ? true : undefined} |  | ||||||
|             aria-errormessage={ |  | ||||||
|               actionData?.errors?.body ? "body-error" : undefined |  | ||||||
|             } |  | ||||||
|           /> |  | ||||||
|         </label> |  | ||||||
|         {actionData?.errors?.body && ( |  | ||||||
|           <Alert className="pt-1 text-red-700" id="body=error"> |  | ||||||
|             {actionData.errors.body} |  | ||||||
|           </Alert> |  | ||||||
|         )} |  | ||||||
|       </div> |  | ||||||
| 
 |  | ||||||
|       <div className="text-right"> |  | ||||||
|         <button |  | ||||||
|           type="submit" |  | ||||||
|           className="rounded bg-blue-500  py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400" |  | ||||||
|         > |  | ||||||
|           Save |  | ||||||
|         </button> |  | ||||||
|       </div> |  | ||||||
|     </Form> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
|  | @ -1,8 +1,8 @@ | ||||||
| import { createCookieSessionStorage, redirect } from "remix"; | import { createCookieSessionStorage, redirect } from "remix"; | ||||||
| import invariant from "tiny-invariant"; | import invariant from "tiny-invariant"; | ||||||
| 
 | 
 | ||||||
| import type { User } from "~/models/user.server"; | import { discordIdentify, User, SessionInformation } from "~/models/user.server"; | ||||||
| import { getUserById } from "~/models/user.server"; | import { getUserByDiscordId } from "~/models/user.server"; | ||||||
| 
 | 
 | ||||||
| invariant(process.env.SESSION_SECRET, "SESSION_SECRET must be set"); | invariant(process.env.SESSION_SECRET, "SESSION_SECRET must be set"); | ||||||
| 
 | 
 | ||||||
|  | @ -19,6 +19,8 @@ export const sessionStorage = createCookieSessionStorage({ | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| const USER_SESSION_KEY = "userId"; | const USER_SESSION_KEY = "userId"; | ||||||
|  | const BEARER_TOKEN_KEY = "bearerToken"; | ||||||
|  | const REFRESH_TOKEN_KEY = "refreshToken"; | ||||||
| 
 | 
 | ||||||
| export async function getSession(request: Request) { | export async function getSession(request: Request) { | ||||||
|   const cookie = request.headers.get("Cookie"); |   const cookie = request.headers.get("Cookie"); | ||||||
|  | @ -35,7 +37,7 @@ export async function getUser(request: Request): Promise<null | User> { | ||||||
|   const userId = await getUserId(request); |   const userId = await getUserId(request); | ||||||
|   if (userId === undefined) return null; |   if (userId === undefined) return null; | ||||||
| 
 | 
 | ||||||
|   const user = await getUserById(userId); |   const user = await getUserByDiscordId(userId); | ||||||
|   if (user) return user; |   if (user) return user; | ||||||
| 
 | 
 | ||||||
|   throw await logout(request); |   throw await logout(request); | ||||||
|  | @ -48,39 +50,38 @@ export async function requireUserId( | ||||||
|   const userId = await getUserId(request); |   const userId = await getUserId(request); | ||||||
|   if (!userId) { |   if (!userId) { | ||||||
|     const searchParams = new URLSearchParams([["redirectTo", redirectTo]]); |     const searchParams = new URLSearchParams([["redirectTo", redirectTo]]); | ||||||
|     throw redirect(`/login?${searchParams}`); |     throw redirect(`/loginstart?${searchParams}`); | ||||||
|   } |   } | ||||||
|   return userId; |   return userId; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function requireUser(request: Request) { | export async function requireUser(request: Request) { | ||||||
|   const userId = await requireUserId(request); |   const session = await getSession(request); | ||||||
| 
 |   const user_id = session.get(USER_SESSION_KEY); | ||||||
|   const user = await getUserById(userId); |   const client_id = process.env.DISCORD_CLIENT_ID || ""; | ||||||
|   if (user) return user; |   const redirect_uri = process.env.DISCORD_REDIRECT_URI || ""; | ||||||
| 
 |   if (!user_id) { | ||||||
|   throw await logout(request); |     return redirect(`https://discord.com/api/oauth2/authorize?client_id=${client_id}` + | ||||||
|  |     `&response_type=code&redirect_uri=${encodeURIComponent(redirect_uri)}&scope=identify`); | ||||||
|  |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function createUserSession({ | export async function createUserSession({ | ||||||
|   request, |   request, | ||||||
|   userId, |   discord_id, | ||||||
|   remember, |  | ||||||
|   redirectTo, |   redirectTo, | ||||||
| }: { | }: { | ||||||
|   request: Request; |   request: Request; | ||||||
|   userId: string; |   discord_id: string; | ||||||
|   remember: boolean; |  | ||||||
|   redirectTo: string; |   redirectTo: string; | ||||||
| }) { | }) { | ||||||
|   const session = await getSession(request); |   const session = await getSession(request); | ||||||
|   session.set(USER_SESSION_KEY, userId); |   session.set(USER_SESSION_KEY, discord_id); | ||||||
|  |   console.log(discord_id); | ||||||
|   return redirect(redirectTo, { |   return redirect(redirectTo, { | ||||||
|     headers: { |     headers: { | ||||||
|       "Set-Cookie": await sessionStorage.commitSession(session, { |       "Set-Cookie": await sessionStorage.commitSession(session, { | ||||||
|         maxAge: remember |         maxAge: 60 * 60 * 24 * 7 // 7 days
 | ||||||
|           ? 60 * 60 * 24 * 7 // 7 days
 |  | ||||||
|           : undefined, |  | ||||||
|       }), |       }), | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  | @ -1,13 +0,0 @@ | ||||||
| import { validateEmail } from "./utils"; |  | ||||||
| 
 |  | ||||||
| test("validateEmail returns false for non-emails", () => { |  | ||||||
|   expect(validateEmail(undefined)).toBe(false); |  | ||||||
|   expect(validateEmail(null)).toBe(false); |  | ||||||
|   expect(validateEmail("")).toBe(false); |  | ||||||
|   expect(validateEmail("not-an-email")).toBe(false); |  | ||||||
|   expect(validateEmail("n@")).toBe(false); |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| test("validateEmail returns true for emails", () => { |  | ||||||
|   expect(validateEmail("kody@example.com")).toBe(true); |  | ||||||
| }); |  | ||||||
|  | @ -21,11 +21,12 @@ export function useMatchesData( | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function isUser(user: any): user is User { | function isUser(user: any): user is User { | ||||||
|   return user && typeof user === "object" && typeof user.email === "string"; |   return user && typeof user === "object" && typeof user.discord_id === "string"; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function useOptionalUser(): User | undefined { | export function useOptionalUser(): User | undefined { | ||||||
|   const data = useMatchesData("root"); |   const data = useMatchesData("root"); | ||||||
|  |   console.log(data) | ||||||
|   if (!data || !isUser(data.user)) { |   if (!data || !isUser(data.user)) { | ||||||
|     return undefined; |     return undefined; | ||||||
|   } |   } | ||||||
|  | @ -41,7 +42,3 @@ export function useUser(): User { | ||||||
|   } |   } | ||||||
|   return maybeUser; |   return maybeUser; | ||||||
| } | } | ||||||
| 
 |  | ||||||
| export function validateEmail(email: unknown): email is string { |  | ||||||
|   return typeof email === "string" && email.length > 3 && email.includes("@"); |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -12,10 +12,11 @@ | ||||||
|     "dev": "pm2-dev ./other/pm2.config.js", |     "dev": "pm2-dev ./other/pm2.config.js", | ||||||
|     "docker": "docker-compose up -d", |     "docker": "docker-compose up -d", | ||||||
|     "format": "prettier --write .", |     "format": "prettier --write .", | ||||||
|     "generate:css": "tailwindcss -o ./app/styles/tailwind.css", |     "generate:css": "tailwindcss -i ./styles/tailwind.css -o ./app/styles/tailwind.css", | ||||||
|     "postinstall": "remix setup node", |     "postinstall": "remix setup node", | ||||||
|     "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", |     "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", | ||||||
|     "setup": "prisma migrate dev && prisma db seed", |     "setup": "prisma migrate dev && prisma db seed", | ||||||
|  |     "db:generate": "prisma generate", | ||||||
|     "start": "cross-env NODE_ENV=production node ./build/server.js", |     "start": "cross-env NODE_ENV=production node ./build/server.js", | ||||||
|     "start:mocks": "cross-env NODE_ENV=production node --require ./mocks --require dotenv/config ./build/server.js", |     "start:mocks": "cross-env NODE_ENV=production node --require ./mocks --require dotenv/config ./build/server.js", | ||||||
|     "test": "vitest", |     "test": "vitest", | ||||||
|  | @ -37,6 +38,7 @@ | ||||||
|     "@reach/alert": "^0.16.0", |     "@reach/alert": "^0.16.0", | ||||||
|     "@remix-run/express": "^1.3.2", |     "@remix-run/express": "^1.3.2", | ||||||
|     "@remix-run/react": "^1.3.2", |     "@remix-run/react": "^1.3.2", | ||||||
|  |     "axios": "^0.26.1", | ||||||
|     "compression": "^1.7.4", |     "compression": "^1.7.4", | ||||||
|     "cross-env": "^7.0.3", |     "cross-env": "^7.0.3", | ||||||
|     "express": "^4.17.3", |     "express": "^4.17.3", | ||||||
|  |  | ||||||
							
								
								
									
										8721
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										8721
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										42
									
								
								prisma/migrations/20220322231835_discord_oauth/migration.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								prisma/migrations/20220322231835_discord_oauth/migration.sql
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,42 @@ | ||||||
|  | /* | ||||||
|  |   Warnings: | ||||||
|  | 
 | ||||||
|  |   - You are about to drop the column `email` on the `User` table. All the data in the column will be lost. | ||||||
|  |   - You are about to drop the `Note` table. If the table is not empty, all the data it contains will be lost. | ||||||
|  |   - You are about to drop the `Password` table. If the table is not empty, all the data it contains 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. | ||||||
|  | 
 | ||||||
|  | */ | ||||||
|  | -- DropForeignKey | ||||||
|  | ALTER TABLE "Note" DROP CONSTRAINT "Note_userId_fkey"; | ||||||
|  | 
 | ||||||
|  | -- DropForeignKey | ||||||
|  | ALTER TABLE "Password" DROP CONSTRAINT "Password_userId_fkey"; | ||||||
|  | 
 | ||||||
|  | -- DropIndex | ||||||
|  | DROP INDEX "User_email_key"; | ||||||
|  | 
 | ||||||
|  | -- AlterTable | ||||||
|  | ALTER TABLE "User" DROP COLUMN "email", | ||||||
|  | ADD COLUMN     "border_id" TEXT, | ||||||
|  | ADD COLUMN     "discord_id" TEXT; | ||||||
|  | 
 | ||||||
|  | -- DropTable | ||||||
|  | DROP TABLE "Note"; | ||||||
|  | 
 | ||||||
|  | -- DropTable | ||||||
|  | DROP TABLE "Password"; | ||||||
|  | 
 | ||||||
|  | -- CreateTable | ||||||
|  | CREATE TABLE "Border" ( | ||||||
|  |     "id" TEXT NOT NULL, | ||||||
|  |     "filename" TEXT NOT NULL, | ||||||
|  | 
 | ||||||
|  |     CONSTRAINT "Border_pkey" PRIMARY KEY ("id") | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | -- CreateIndex | ||||||
|  | CREATE UNIQUE INDEX "Border_filename_key" ON "Border"("filename"); | ||||||
|  | 
 | ||||||
|  | -- CreateIndex | ||||||
|  | CREATE UNIQUE INDEX "User_discord_id_key" ON "User"("discord_id"); | ||||||
|  | @ -9,30 +9,25 @@ generator client { | ||||||
| 
 | 
 | ||||||
| model User { | model User { | ||||||
|   id    String @id @default(cuid()) |   id    String @id @default(cuid()) | ||||||
|   email String @unique |   discord_id  String? @unique | ||||||
|  |   border_id   String? | ||||||
| 
 | 
 | ||||||
|   createdAt DateTime @default(now()) |   createdAt DateTime @default(now()) | ||||||
|   updatedAt DateTime @updatedAt |   updatedAt DateTime @updatedAt | ||||||
| 
 |  | ||||||
|   password Password? |  | ||||||
|   notes    Note[] |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| model Password { | model DiscordUser { | ||||||
|   hash String |   id String @id @default(cuid()) | ||||||
|  |   discord_id String @unique | ||||||
|  |   access_token String? | ||||||
|  |   refresh_token String? | ||||||
|  |   expires DateTime? | ||||||
| 
 | 
 | ||||||
|   user   User   @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) |   username String? | ||||||
|   userId String @unique |   discriminator String? | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| model Note { | model Border { | ||||||
|   id    String @id @default(cuid()) |   id String @id @default(cuid()) | ||||||
|   title String |   filename String @unique | ||||||
|   body  String |  | ||||||
| 
 |  | ||||||
|   createdAt DateTime @default(now()) |  | ||||||
|   updatedAt DateTime @updatedAt |  | ||||||
| 
 |  | ||||||
|   user   User   @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) |  | ||||||
|   userId String |  | ||||||
| } | } | ||||||
|  | @ -1,42 +1,20 @@ | ||||||
| import { PrismaClient } from "@prisma/client"; | import { PrismaClient } from "@prisma/client"; | ||||||
| import bcrypt from "@node-rs/bcrypt"; |  | ||||||
| 
 | 
 | ||||||
| const prisma = new PrismaClient(); | const prisma = new PrismaClient(); | ||||||
| 
 | 
 | ||||||
| async function seed() { | async function seed() { | ||||||
|   const email = "rachel@remix.run"; |   const discord_id = "123601647258697730"; | ||||||
|  |   const border_id = 'test'; | ||||||
| 
 | 
 | ||||||
|   // cleanup the existing database
 |   // cleanup the existing database
 | ||||||
|   await prisma.user.delete({ where: { email } }).catch(() => { |   await prisma.user.delete({ where: { discord_id } }).catch(() => { | ||||||
|     // no worries if it doesn't exist yet
 |     // no worries if it doesn't exist yet
 | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   const hashedPassword = await bcrypt.hash("racheliscool", 10); |  | ||||||
| 
 |  | ||||||
|   const user = await prisma.user.create({ |   const user = await prisma.user.create({ | ||||||
|     data: { |     data: { | ||||||
|       email, |       discord_id, | ||||||
|       password: { |       border_id | ||||||
|         create: { |  | ||||||
|           hash: hashedPassword, |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|     }, |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   await prisma.note.create({ |  | ||||||
|     data: { |  | ||||||
|       title: "My first note", |  | ||||||
|       body: "Hello, world!", |  | ||||||
|       userId: user.id, |  | ||||||
|     }, |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   await prisma.note.create({ |  | ||||||
|     data: { |  | ||||||
|       title: "My second note", |  | ||||||
|       body: "Hello, world!", |  | ||||||
|       userId: user.id, |  | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										
											BIN
										
									
								
								public/sky.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/sky.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 118 KiB | 
							
								
								
									
										9
									
								
								styles/tailwind.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								styles/tailwind.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,9 @@ | ||||||
|  | @tailwind base; | ||||||
|  | @tailwind components; | ||||||
|  | @tailwind utilities; | ||||||
|  | 
 | ||||||
|  | html { | ||||||
|  |     background-image: url("/sky.png"); | ||||||
|  |     background-repeat: no-repeat; | ||||||
|  |     background-size: cover; | ||||||
|  | } | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue