diff --git a/.env.example b/.env.example index 5768ed18..dcf61999 100644 --- a/.env.example +++ b/.env.example @@ -8,14 +8,17 @@ NX_POLYGON_API_KEY= # If using free ngrok account for webhooks NGROK_AUTH_TOKEN= -# Required for Auth0 deploy client (see `yarn auth0:deploy` command) -AUTH0_ENV=development -NX_AUTH0_MGMT_CLIENT_SECRET= -NX_AUTH0_CLIENT_SECRET= -AUTH0_DEPLOY_CLIENT_SECRET= POSTMARK_SMTP_PASS= NX_SESSION_SECRET= +# Generate a new secret using openssl rand -base64 32 +NEXTAUTH_SECRET= +NEXTAUTH_URL=http://localhost:4200 +NX_NEXTAUTH_URL=http://localhost:4200 + NX_PLAID_SECRET= NX_FINICITY_APP_KEY= NX_FINICITY_PARTNER_SECRET= +NX_CONVERTKIT_SECRET= + +NEXT_PUBLIC_ZAPIER_FEEDBACK_HOOK_URL= diff --git a/README.md b/README.md index 03281b11..d2e47717 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,17 @@ This is the current state of building the app. You'll hit errors, which we're wo You'll need Docker installed to run the app locally. +First, copy the `.env.example` file to `.env`: + ``` cp .env.example .env +``` + +Then, create a new secret using `openssl rand -base64 32` and populate `NEXTAUTH_SECRET` in your `.env` file with it. + +Then run the following yarn commands: + +``` yarn install yarn run dev:services yarn prisma:migrate:dev diff --git a/apps/client/components/APM.tsx b/apps/client/components/APM.tsx index 94e858c3..f043261d 100644 --- a/apps/client/components/APM.tsx +++ b/apps/client/components/APM.tsx @@ -1,19 +1,19 @@ -import { useAuth0 } from '@auth0/auth0-react' import { useEffect } from 'react' import * as Sentry from '@sentry/react' +import { useSession } from 'next-auth/react' export default function APM() { - const { user } = useAuth0() + const { data: session } = useSession() // Identify Sentry user useEffect(() => { - if (user) { + if (session && session.user) { Sentry.setUser({ - id: user.sub, - email: user.email, + id: session.user['sub'] ?? undefined, + email: session.user['https://maybe.co'] ?? undefined, }) } - }, [user]) + }, [session]) return null } diff --git a/apps/client/components/ModalManager.tsx b/apps/client/components/ModalManager.tsx deleted file mode 100644 index cbf7800a..00000000 --- a/apps/client/components/ModalManager.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { - type ModalKey, - type ModalManagerAction, - ModalManagerContext, - useUserApi, - useLocalStorage, -} from '@maybe-finance/client/shared' -import { type PropsWithChildren, useReducer, useEffect } from 'react' -import { LinkAccountFlow } from '@maybe-finance/client/features' - -function reducer( - state: Record, - action: ModalManagerAction -) { - switch (action.type) { - case 'open': - return { ...state, [action.key]: { isOpen: true, props: action.props } } - case 'close': - return { ...state, [action.key]: { isOpen: false, props: null } } - } -} - -/** - * Manages auto-prompt modals and regular modals to avoid stacking collisions - */ -export default function ModalManager({ children }: PropsWithChildren) { - const [accountLinkHidden, setAccountLinkHidden] = useLocalStorage('account-link-hidden', false) - - const [state, dispatch] = useReducer(reducer, { - linkAuth0Accounts: { isOpen: false, props: null }, - }) - - const allClosed = Object.values(state).every((v) => v.isOpen === false) - - const { useAuth0Profile } = useUserApi() - const auth0Profile = useAuth0Profile() - - useEffect(() => { - const autoPrompt = auth0Profile.data?.autoPromptEnabled === true - const provider = auth0Profile.data?.suggestedIdentities?.[0]?.provider - - if (autoPrompt && provider && allClosed && !accountLinkHidden) { - dispatch({ - type: 'open', - key: 'linkAuth0Accounts', - props: { secondaryProvider: provider }, - }) - } - }, [auth0Profile.data, dispatch, allClosed, accountLinkHidden]) - - return ( - - {children} - - { - dispatch({ type: 'close', key: 'linkAuth0Accounts' }) - setAccountLinkHidden(true) - }} - {...state.linkAuth0Accounts.props} - /> - - ) -} diff --git a/apps/client/env.ts b/apps/client/env.ts index 0e00bd5f..3d38de48 100644 --- a/apps/client/env.ts +++ b/apps/client/env.ts @@ -1,13 +1,10 @@ const env = { NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3333', - NEXT_PUBLIC_AUTH0_DOMAIN: - process.env.NEXT_PUBLIC_AUTH0_DOMAIN || 'REPLACE_THIS', - NEXT_PUBLIC_AUTH0_CLIENT_ID: - process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID || 'REPLACE_THIS', + NEXT_PUBLIC_AUTH0_DOMAIN: process.env.NEXT_PUBLIC_AUTH0_DOMAIN || 'REPLACE_THIS', + NEXT_PUBLIC_AUTH0_CLIENT_ID: process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID || 'REPLACE_THIS', NEXT_PUBLIC_AUTH0_AUDIENCE: process.env.NEXT_PUBLIC_AUTH0_AUDIENCE || 'https://maybe-finance-api/v1', - NEXT_PUBLIC_LD_CLIENT_SIDE_ID: - process.env.NEXT_PUBLIC_LD_CLIENT_SIDE_ID || 'REPLACE_THIS', + NEXT_PUBLIC_LD_CLIENT_SIDE_ID: process.env.NEXT_PUBLIC_LD_CLIENT_SIDE_ID || 'REPLACE_THIS', NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN, NEXT_PUBLIC_SENTRY_ENV: process.env.NEXT_PUBLIC_SENTRY_ENV, } diff --git a/apps/client/pages/_app.tsx b/apps/client/pages/_app.tsx index 4fde4633..d1fb65c9 100644 --- a/apps/client/pages/_app.tsx +++ b/apps/client/pages/_app.tsx @@ -1,4 +1,4 @@ -import type { PropsWithChildren, ReactElement } from 'react' +import { useEffect, type PropsWithChildren, type ReactElement } from 'react' import type { AppProps } from 'next/app' import { ErrorBoundary } from 'react-error-boundary' import { Analytics } from '@vercel/analytics/react' @@ -8,18 +8,17 @@ import { ErrorFallback, LogProvider, UserAccountContextProvider, - AuthProvider, } from '@maybe-finance/client/shared' -import { AccountsManager } from '@maybe-finance/client/features' +import { AccountsManager, OnboardingGuard } from '@maybe-finance/client/features' import { AccountContextProvider } from '@maybe-finance/client/shared' import * as Sentry from '@sentry/react' import { BrowserTracing } from '@sentry/tracing' import env from '../env' import '../styles.css' -import { withAuthenticationRequired } from '@auth0/auth0-react' -import ModalManager from '../components/ModalManager' +import { SessionProvider, useSession } from 'next-auth/react' import Meta from '../components/Meta' import APM from '../components/APM' +import { useRouter } from 'next/router' Sentry.init({ dsn: env.NEXT_PUBLIC_SENTRY_DSN, @@ -33,20 +32,30 @@ Sentry.init({ }) // Providers and components only relevant to a logged-in user -const WithAuth = withAuthenticationRequired(function ({ children }: PropsWithChildren) { - return ( - - - - {children} +const WithAuth = function ({ children }: PropsWithChildren) { + const { data: session } = useSession() + const router = useRouter() - {/* Add, edit, delete connections and manual accounts */} - - - - - ) -}) + useEffect(() => { + if (!session) { + router.push('/login') + } + }, [session, router]) + + if (session) { + return ( + + + + {children} + + + + + ) + } + return null +} export default function App({ Component: Page, @@ -71,7 +80,7 @@ export default function App({ - + <> @@ -82,7 +91,7 @@ export default function App({ )} - + diff --git a/apps/client/pages/api/auth/[...nextauth].ts b/apps/client/pages/api/auth/[...nextauth].ts new file mode 100644 index 00000000..3a18412a --- /dev/null +++ b/apps/client/pages/api/auth/[...nextauth].ts @@ -0,0 +1,145 @@ +import NextAuth from 'next-auth' +import type { SessionStrategy, NextAuthOptions } from 'next-auth' +import CredentialsProvider from 'next-auth/providers/credentials' +import { z } from 'zod' +import { PrismaClient, type Prisma } from '@prisma/client' +import { PrismaAdapter } from '@auth/prisma-adapter' +import type { SharedType } from '@maybe-finance/shared' +import bcrypt from 'bcrypt' + +let prismaInstance: PrismaClient | null = null + +function getPrismaInstance() { + if (!prismaInstance) { + prismaInstance = new PrismaClient() + } + return prismaInstance +} + +const prisma = getPrismaInstance() + +async function createAuthUser(data: Prisma.AuthUserCreateInput) { + const authUser = await prisma.authUser.create({ data: { ...data } }) + return authUser +} + +async function getAuthUserByEmail(email: string) { + if (!email) throw new Error('No email provided.') + return await prisma.authUser.findUnique({ + where: { email }, + }) +} + +async function validateCredentials(credentials: any): Promise> { + const authSchema = z.object({ + firstName: z.string().optional(), + lastName: z.string().optional(), + email: z.string().email({ message: 'Invalid email address.' }), + password: z.string().min(6), + }) + + const parsed = authSchema.safeParse(credentials) + if (!parsed.success) { + throw new Error(parsed.error.issues.map((issue) => issue.message).join(', ')) + } + + return parsed.data +} + +async function createNewAuthUser(credentials: { + firstName: string + lastName: string + email: string + password: string +}): Promise { + const { firstName, lastName, email, password } = credentials + + if (!firstName || !lastName) { + throw new Error('Both first name and last name are required.') + } + + const hashedPassword = await bcrypt.hash(password, 10) + return createAuthUser({ + firstName, + lastName, + name: `${firstName} ${lastName}`, + email, + password: hashedPassword, + }) +} + +const authPrisma = { + account: prisma.authAccount, + user: prisma.authUser, + session: prisma.authSession, + verificationToken: prisma.authVerificationToken, +} as unknown as PrismaClient + +export const authOptions = { + adapter: PrismaAdapter(authPrisma), + secret: process.env.NEXTAUTH_SECRET || 'CHANGE_ME', + pages: { + signIn: '/login', + }, + session: { + strategy: 'jwt' as SessionStrategy, + maxAge: 1 * 24 * 60 * 60, // 1 Day + }, + + providers: [ + CredentialsProvider({ + name: 'Credentials', + type: 'credentials', + credentials: { + firstName: { label: 'First name', type: 'text', placeholder: 'First name' }, + lastName: { label: 'Last name', type: 'text', placeholder: 'Last name' }, + email: { label: 'Email', type: 'email', placeholder: 'hello@maybe.co' }, + password: { label: 'Password', type: 'password' }, + }, + async authorize(credentials) { + const { firstName, lastName, email, password } = await validateCredentials( + credentials + ) + + const existingUser = await getAuthUserByEmail(email) + if (existingUser) { + const isPasswordMatch = await bcrypt.compare(password, existingUser.password!) + if (!isPasswordMatch) { + throw new Error('Email or password is invalid.') + } + + return existingUser + } + + if (!firstName || !lastName) { + throw new Error('Invalid credentials provided.') + } + + return createNewAuthUser({ firstName, lastName, email, password }) + }, + }), + ], + callbacks: { + async jwt({ token, user: authUser }: { token: any; user: any }) { + if (authUser) { + token.sub = authUser.id + token['https://maybe.co/email'] = authUser.email + token.firstName = authUser.firstName + token.lastName = authUser.lastName + token.name = authUser.name + } + return token + }, + async session({ session, token }: { session: any; token: any }) { + session.user = token.sub + session.sub = token.sub + session['https://maybe.co/email'] = token['https://maybe.co/email'] + session.firstName = token.firstName + session.lastName = token.lastName + session.name = token.name + return session + }, + }, +} as NextAuthOptions + +export default NextAuth(authOptions) diff --git a/apps/client/pages/login.tsx b/apps/client/pages/login.tsx index a6a393fc..84634495 100644 --- a/apps/client/pages/login.tsx +++ b/apps/client/pages/login.tsx @@ -1,20 +1,116 @@ -import { useAuth0 } from '@auth0/auth0-react' -import { LoadingSpinner } from '@maybe-finance/design-system' +import { useState, type ReactElement } from 'react' +import { FullPageLayout } from '@maybe-finance/client/features' +import { Input, InputPassword, Button } from '@maybe-finance/design-system' +import { signIn, useSession } from 'next-auth/react' import { useRouter } from 'next/router' import { useEffect } from 'react' +import Script from 'next/script' +import Link from 'next/link' export default function LoginPage() { - const { isAuthenticated } = useAuth0() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [isValid, setIsValid] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + + const { data: session } = useSession() const router = useRouter() useEffect(() => { - if (isAuthenticated) router.push('/') - }, [isAuthenticated, router]) + if (session) { + router.push('/') + } + }, [session, router]) + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setErrorMessage(null) + setPassword('') + + const response = await signIn('credentials', { + email, + password, + redirect: false, + }) + + if (response && response.error) { + setErrorMessage(response.error) + } + } - // _app.tsx will automatically redirect if not authenticated return ( -
- -
+ <> +