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">
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<img
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
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
|
<Link
|
||||||
to="/notes"
|
to="/loginstart">
|
||||||
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"
|
Log in
|
||||||
>
|
|
||||||
View Notes for {user.email}
|
|
||||||
</Link>
|
</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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
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
Normal file
8721
pnpm-lock.yaml
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
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
|
||||||
userId String @unique
|
|
||||||
}
|
|
||||||
|
|
||||||
model Note {
|
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
discord_id String @unique
|
||||||
body String
|
access_token String?
|
||||||
|
refresh_token String?
|
||||||
|
expires DateTime?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
username String?
|
||||||
updatedAt DateTime @updatedAt
|
discriminator String?
|
||||||
|
}
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
|
||||||
userId String
|
model Border {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
filename String @unique
|
||||||
}
|
}
|
|
@ -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…
Reference in a new issue