1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 07:25:19 +02:00

Merge pull request #37 from tmyracle/replace-auth

Replace Auth
This commit is contained in:
Josh Pigford 2024-01-14 12:36:25 -06:00 committed by GitHub
commit 47056203d0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
82 changed files with 1078 additions and 1640 deletions

View file

@ -8,14 +8,17 @@ NX_POLYGON_API_KEY=
# If using free ngrok account for webhooks # If using free ngrok account for webhooks
NGROK_AUTH_TOKEN= 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= POSTMARK_SMTP_PASS=
NX_SESSION_SECRET= 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_PLAID_SECRET=
NX_FINICITY_APP_KEY= NX_FINICITY_APP_KEY=
NX_FINICITY_PARTNER_SECRET= NX_FINICITY_PARTNER_SECRET=
NX_CONVERTKIT_SECRET=
NEXT_PUBLIC_ZAPIER_FEEDBACK_HOOK_URL=

View file

@ -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. You'll need Docker installed to run the app locally.
First, copy the `.env.example` file to `.env`:
``` ```
cp .env.example .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 install
yarn run dev:services yarn run dev:services
yarn prisma:migrate:dev yarn prisma:migrate:dev

View file

@ -1,19 +1,19 @@
import { useAuth0 } from '@auth0/auth0-react'
import { useEffect } from 'react' import { useEffect } from 'react'
import * as Sentry from '@sentry/react' import * as Sentry from '@sentry/react'
import { useSession } from 'next-auth/react'
export default function APM() { export default function APM() {
const { user } = useAuth0() const { data: session } = useSession()
// Identify Sentry user // Identify Sentry user
useEffect(() => { useEffect(() => {
if (user) { if (session && session.user) {
Sentry.setUser({ Sentry.setUser({
id: user.sub, id: session.user['sub'] ?? undefined,
email: user.email, email: session.user['https://maybe.co'] ?? undefined,
}) })
} }
}, [user]) }, [session])
return null return null
} }

View file

@ -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<ModalKey, { isOpen: boolean; props: any }>,
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 (
<ModalManagerContext.Provider value={{ dispatch }}>
{children}
<LinkAccountFlow
isOpen={state.linkAuth0Accounts.isOpen}
onClose={() => {
dispatch({ type: 'close', key: 'linkAuth0Accounts' })
setAccountLinkHidden(true)
}}
{...state.linkAuth0Accounts.props}
/>
</ModalManagerContext.Provider>
)
}

View file

@ -1,13 +1,10 @@
const env = { const env = {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3333', NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3333',
NEXT_PUBLIC_AUTH0_DOMAIN: NEXT_PUBLIC_AUTH0_DOMAIN: process.env.NEXT_PUBLIC_AUTH0_DOMAIN || 'REPLACE_THIS',
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_CLIENT_ID:
process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID || 'REPLACE_THIS',
NEXT_PUBLIC_AUTH0_AUDIENCE: NEXT_PUBLIC_AUTH0_AUDIENCE:
process.env.NEXT_PUBLIC_AUTH0_AUDIENCE || 'https://maybe-finance-api/v1', process.env.NEXT_PUBLIC_AUTH0_AUDIENCE || 'https://maybe-finance-api/v1',
NEXT_PUBLIC_LD_CLIENT_SIDE_ID: NEXT_PUBLIC_LD_CLIENT_SIDE_ID: process.env.NEXT_PUBLIC_LD_CLIENT_SIDE_ID || 'REPLACE_THIS',
process.env.NEXT_PUBLIC_LD_CLIENT_SIDE_ID || 'REPLACE_THIS',
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN, NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
NEXT_PUBLIC_SENTRY_ENV: process.env.NEXT_PUBLIC_SENTRY_ENV, NEXT_PUBLIC_SENTRY_ENV: process.env.NEXT_PUBLIC_SENTRY_ENV,
} }

View file

@ -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 type { AppProps } from 'next/app'
import { ErrorBoundary } from 'react-error-boundary' import { ErrorBoundary } from 'react-error-boundary'
import { Analytics } from '@vercel/analytics/react' import { Analytics } from '@vercel/analytics/react'
@ -8,18 +8,17 @@ import {
ErrorFallback, ErrorFallback,
LogProvider, LogProvider,
UserAccountContextProvider, UserAccountContextProvider,
AuthProvider,
} from '@maybe-finance/client/shared' } 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 { AccountContextProvider } from '@maybe-finance/client/shared'
import * as Sentry from '@sentry/react' import * as Sentry from '@sentry/react'
import { BrowserTracing } from '@sentry/tracing' import { BrowserTracing } from '@sentry/tracing'
import env from '../env' import env from '../env'
import '../styles.css' import '../styles.css'
import { withAuthenticationRequired } from '@auth0/auth0-react' import { SessionProvider, useSession } from 'next-auth/react'
import ModalManager from '../components/ModalManager'
import Meta from '../components/Meta' import Meta from '../components/Meta'
import APM from '../components/APM' import APM from '../components/APM'
import { useRouter } from 'next/router'
Sentry.init({ Sentry.init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN, dsn: env.NEXT_PUBLIC_SENTRY_DSN,
@ -33,20 +32,30 @@ Sentry.init({
}) })
// Providers and components only relevant to a logged-in user // Providers and components only relevant to a logged-in user
const WithAuth = withAuthenticationRequired(function ({ children }: PropsWithChildren) { const WithAuth = function ({ children }: PropsWithChildren) {
const { data: session } = useSession()
const router = useRouter()
useEffect(() => {
if (!session) {
router.push('/login')
}
}, [session, router])
if (session) {
return ( return (
<ModalManager> <OnboardingGuard>
<UserAccountContextProvider> <UserAccountContextProvider>
<AccountContextProvider> <AccountContextProvider>
{children} {children}
{/* Add, edit, delete connections and manual accounts */}
<AccountsManager /> <AccountsManager />
</AccountContextProvider> </AccountContextProvider>
</UserAccountContextProvider> </UserAccountContextProvider>
</ModalManager> </OnboardingGuard>
) )
}) }
return null
}
export default function App({ export default function App({
Component: Page, Component: Page,
@ -71,7 +80,7 @@ export default function App({
<Meta /> <Meta />
<Analytics /> <Analytics />
<QueryProvider> <QueryProvider>
<AuthProvider> <SessionProvider>
<AxiosProvider> <AxiosProvider>
<> <>
<APM /> <APM />
@ -82,7 +91,7 @@ export default function App({
)} )}
</> </>
</AxiosProvider> </AxiosProvider>
</AuthProvider> </SessionProvider>
</QueryProvider> </QueryProvider>
</ErrorBoundary> </ErrorBoundary>
</LogProvider> </LogProvider>

View file

@ -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<z.infer<typeof authSchema>> {
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<SharedType.AuthUser> {
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)

View file

@ -1,20 +1,116 @@
import { useAuth0 } from '@auth0/auth0-react' import { useState, type ReactElement } from 'react'
import { LoadingSpinner } from '@maybe-finance/design-system' 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 { useRouter } from 'next/router'
import { useEffect } from 'react' import { useEffect } from 'react'
import Script from 'next/script'
import Link from 'next/link'
export default function LoginPage() { export default function LoginPage() {
const { isAuthenticated } = useAuth0() const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isValid, setIsValid] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const { data: session } = useSession()
const router = useRouter() const router = useRouter()
useEffect(() => { useEffect(() => {
if (isAuthenticated) router.push('/') if (session) {
}, [isAuthenticated, router]) router.push('/')
}
}, [session, router])
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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 ( return (
<div className="absolute inset-0 flex items-center justify-center"> <>
<LoadingSpinner /> <Script
src="https://cdnjs.cloudflare.com/ajax/libs/zxcvbn/4.4.2/zxcvbn.js"
strategy="lazyOnload"
/>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<div className="p-px w-80 md:w-96 bg-white bg-opacity-10 card-light rounded-3xl radial-gradient-background">
<div className="bg-black bg-opacity-75 p-8 rounded-3xl w-full h-full items-center flex flex-col radial-gradient-background-dark">
<img
className="mb-8"
src="/assets/maybe-box.svg"
alt="Maybe Finance Logo"
height={120}
width={120}
/>
<form className="space-y-4 w-full px-4" onSubmit={onSubmit}>
<Input
type="text"
label="Email"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
/>
<InputPassword
autoComplete="password"
label="Password"
value={password}
showPasswordRequirements={!isValid}
onValidityChange={(checks) => {
const passwordValid =
checks.filter((c) => !c.isValid).length === 0
setIsValid(passwordValid)
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPassword(e.target.value)
}
/>
{errorMessage && password.length === 0 ? (
<div className="py-1 text-center text-red text-sm">
{errorMessage}
</div> </div>
) : null}
<Button
type="submit"
disabled={!isValid}
variant={isValid ? 'primary' : 'secondary'}
>
Log in
</Button>
<div className="text-sm text-gray-50 pt-2">
<div>
Don&apos;t have an account?{' '}
<Link
className="hover:text-cyan-400 underline font-medium"
href="/register"
>
Sign up
</Link>
</div>
</div>
</form>
</div>
</div>
</div>
</>
) )
} }
LoginPage.getLayout = function getLayout(page: ReactElement) {
return <FullPageLayout>{page}</FullPageLayout>
}
LoginPage.isPublic = true

View file

@ -1,20 +1,134 @@
import { useAuth0 } from '@auth0/auth0-react' import { useState, type ReactElement } from 'react'
import { LoadingSpinner } from '@maybe-finance/design-system' import { Input, InputPassword, Button } from '@maybe-finance/design-system'
import { FullPageLayout } from '@maybe-finance/client/features'
import { signIn, useSession } from 'next-auth/react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useEffect } from 'react' import { useEffect } from 'react'
import Script from 'next/script'
import Link from 'next/link'
export default function RegisterPage() { export default function RegisterPage() {
const { isAuthenticated } = useAuth0() const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isValid, setIsValid] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const { data: session } = useSession()
const router = useRouter() const router = useRouter()
useEffect(() => { useEffect(() => {
if (isAuthenticated) router.push('/') if (session) router.push('/')
}, [isAuthenticated, router]) }, [session, router])
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setErrorMessage(null)
setFirstName('')
setLastName('')
setEmail('')
setPassword('')
const response = await signIn('credentials', {
email,
password,
firstName,
lastName,
redirect: false,
})
if (response && response.error) {
setErrorMessage(response.error)
}
}
// _app.tsx will automatically redirect if not authenticated
return ( return (
<div className="absolute inset-0 flex items-center justify-center"> <>
<LoadingSpinner /> <Script
src="https://cdnjs.cloudflare.com/ajax/libs/zxcvbn/4.4.2/zxcvbn.js"
strategy="lazyOnload"
/>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<div className="p-px w-96 bg-white bg-opacity-10 card-light rounded-3xl radial-gradient-background">
<div className="bg-black bg-opacity-75 p-8 rounded-3xl w-full h-full items-center flex flex-col radial-gradient-background-dark">
<img
className="mb-8"
src="/assets/maybe-box.svg"
alt="Maybe Finance Logo"
height={120}
width={120}
/>
<form className="space-y-4 w-full px-4" onSubmit={onSubmit}>
<Input
type="text"
label="First name"
value={firstName}
onChange={(e) => setFirstName(e.currentTarget.value)}
/>
<Input
type="text"
label="Last name"
value={lastName}
onChange={(e) => setLastName(e.currentTarget.value)}
/>
<Input
type="text"
label="Email"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
/>
<InputPassword
autoComplete="password"
label="Password"
value={password}
showPasswordRequirements={!isValid}
onValidityChange={(checks) => {
const passwordValid =
checks.filter((c) => !c.isValid).length === 0
setIsValid(passwordValid)
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPassword(e.target.value)
}
/>
{errorMessage && password.length === 0 ? (
<div className="py-1 text-center text-red text-sm">
{errorMessage}
</div> </div>
) : null}
<Button
type="submit"
disabled={!isValid}
variant={isValid ? 'primary' : 'secondary'}
>
Register
</Button>
<div className="text-sm text-gray-50 pt-2">
<div>
Already have an account?{' '}
<Link
className="hover:text-cyan-400 underline font-medium"
href="/login"
>
Sign in
</Link>
</div>
</div>
</form>
</div>
</div>
</div>
</>
) )
} }
RegisterPage.getLayout = function getLayout(page: ReactElement) {
return <FullPageLayout>{page}</FullPageLayout>
}
RegisterPage.isPublic = true

View file

@ -3,7 +3,6 @@ import { useQueryParam } from '@maybe-finance/client/shared'
import { import {
AccountSidebar, AccountSidebar,
BillingPreferences, BillingPreferences,
GeneralPreferences,
SecurityPreferences, SecurityPreferences,
UserDetails, UserDetails,
WithSidebarLayout, WithSidebarLayout,
@ -35,7 +34,6 @@ export default function SettingsPage() {
> >
<Tab.List> <Tab.List>
<Tab>Details</Tab> <Tab>Details</Tab>
<Tab>Notifications</Tab>
<Tab>Security</Tab> <Tab>Security</Tab>
<Tab>Billing</Tab> <Tab>Billing</Tab>
</Tab.List> </Tab.List>
@ -43,11 +41,6 @@ export default function SettingsPage() {
<Tab.Panel> <Tab.Panel>
<UserDetails /> <UserDetails />
</Tab.Panel> </Tab.Panel>
<Tab.Panel>
<div className="mt-6 max-w-lg text-base">
<GeneralPreferences />
</div>
</Tab.Panel>
<Tab.Panel> <Tab.Panel>
<div className="mt-6 max-w-lg"> <div className="mt-6 max-w-lg">
<SecurityPreferences /> <SecurityPreferences />

View file

@ -145,3 +145,20 @@
height: 0; height: 0;
pointer-events: none; pointer-events: none;
} }
.radial-gradient-background {
background-image: radial-gradient(
60% 200% at 50% 50%,
rgba(67, 97, 238, 0.5) 0%,
transparent 100%
);
}
.radial-gradient-background-dark {
background-image: radial-gradient(
100% 100% at clamp(20%, calc(30% + var(--mx) * 0.05), 40%)
clamp(50%, calc(50% + var(--my) * 0.05), 60%),
#4361ee33 0%,
#16161af4 120%
);
}

View file

@ -4,7 +4,6 @@ import { createLogger, transports } from 'winston'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import { PgService } from '@maybe-finance/server/shared' import { PgService } from '@maybe-finance/server/shared'
import { AccountQueryService, UserService } from '@maybe-finance/server/features' import { AccountQueryService, UserService } from '@maybe-finance/server/features'
import { managementClient } from '../lib/auth0'
import { resetUser } from './utils/user' import { resetUser } from './utils/user'
jest.mock('plaid') jest.mock('plaid')
jest.mock('auth0') jest.mock('auth0')
@ -38,7 +37,6 @@ describe('user net worth', () => {
}, },
{} as any, {} as any,
{} as any, {} as any,
managementClient,
{} as any {} as any
) )

View file

@ -3,7 +3,6 @@ import { PrismaClient } from '@prisma/client'
import { createLogger, transports } from 'winston' import { createLogger, transports } from 'winston'
import { AccountQueryService, UserService } from '@maybe-finance/server/features' import { AccountQueryService, UserService } from '@maybe-finance/server/features'
import { resetUser } from './utils/user' import { resetUser } from './utils/user'
import { managementClient } from '../lib/auth0'
import stripe from '../lib/stripe' import stripe from '../lib/stripe'
import { PgService } from '@maybe-finance/server/shared' import { PgService } from '@maybe-finance/server/shared'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
@ -18,7 +17,6 @@ const userService = new UserService(
{} as any, {} as any,
{} as any, {} as any,
{} as any, {} as any,
managementClient,
stripe stripe
) )

View file

@ -23,10 +23,7 @@ export async function getAxiosClient() {
password: 'REPLACE_THIS', password: 'REPLACE_THIS',
audience: 'https://maybe-finance-api/v1', audience: 'https://maybe-finance-api/v1',
scope: '', scope: '',
client_id: isCI client_id: isCI ? 'REPLACE_THIS' : 'REPLACE_THIS',
? 'REPLACE_THIS'
: 'REPLACE_THIS',
client_secret: env.NX_AUTH0_CLIENT_SECRET,
}, },
}) })

View file

@ -19,7 +19,7 @@ import logger from './lib/logger'
import prisma from './lib/prisma' import prisma from './lib/prisma'
import { import {
defaultErrorHandler, defaultErrorHandler,
validateAuth0Jwt, validateAuthJwt,
superjson, superjson,
authErrorHandler, authErrorHandler,
maintenance, maintenance,
@ -30,7 +30,6 @@ import {
usersRouter, usersRouter,
accountsRouter, accountsRouter,
connectionsRouter, connectionsRouter,
adminRouter,
webhooksRouter, webhooksRouter,
plaidRouter, plaidRouter,
accountRollupRouter, accountRollupRouter,
@ -88,11 +87,10 @@ app.use(express.static(__dirname + '/assets'))
const origin = [env.NX_CLIENT_URL, ...env.NX_CORS_ORIGINS] const origin = [env.NX_CLIENT_URL, ...env.NX_CORS_ORIGINS]
logger.info(`CORS origins: ${origin}`) logger.info(`CORS origins: ${origin}`)
app.use(cors({ origin })) app.use(cors({ origin, credentials: true }))
app.options('*', cors() as RequestHandler) app.options('*', cors() as RequestHandler)
app.set('view engine', 'ejs').set('views', __dirname + '/app/admin/views') app.set('view engine', 'ejs').set('views', __dirname + '/app/admin/views')
app.use('/admin', adminRouter)
app.use( app.use(
morgan(env.NX_MORGAN_LOG_LEVEL, { morgan(env.NX_MORGAN_LOG_LEVEL, {
@ -116,7 +114,7 @@ app.use(express.json({ limit: '50mb' })) // Finicity sends large response bodies
app.use( app.use(
'/trpc', '/trpc',
validateAuth0Jwt, validateAuthJwt,
trpcExpress.createExpressMiddleware({ trpcExpress.createExpressMiddleware({
router: appRouter, router: appRouter,
createContext: createTRPCContext, createContext: createTRPCContext,
@ -150,7 +148,7 @@ app.use('/v1', webhooksRouter)
app.use('/v1', publicRouter) app.use('/v1', publicRouter)
// All routes AFTER this line are protected via OAuth // All routes AFTER this line are protected via OAuth
app.use('/v1', validateAuth0Jwt) app.use('/v1', validateAuthJwt)
// Private routes // Private routes
app.use('/v1/users', usersRouter) app.use('/v1/users', usersRouter)

View file

@ -1,18 +0,0 @@
import type { SharedType } from '@maybe-finance/shared'
import { ManagementClient } from 'auth0'
import env from '../../env'
/**
* Management API Documentation
* - https://auth0.com/docs/api/management/v2
* - https://auth0.github.io/node-auth0/module-management.ManagementClient.html
*/
export const managementClient = new ManagementClient<
SharedType.MaybeAppMetadata,
SharedType.MaybeUserMetadata
>({
domain: env.NX_AUTH0_DOMAIN,
clientId: env.NX_AUTH0_MGMT_CLIENT_ID,
clientSecret: env.NX_AUTH0_MGMT_CLIENT_SECRET,
scope: 'read:users update:users delete:users',
})

View file

@ -24,6 +24,7 @@ import Redis from 'ioredis'
import { import {
AccountService, AccountService,
AccountConnectionService, AccountConnectionService,
AuthUserService,
UserService, UserService,
EmailService, EmailService,
AccountQueryService, AccountQueryService,
@ -56,7 +57,6 @@ import plaid, { getPlaidWebhookUrl } from './plaid'
import finicity, { getFinicityTxPushUrl, getFinicityWebhookUrl } from './finicity' import finicity, { getFinicityTxPushUrl, getFinicityWebhookUrl } from './finicity'
import stripe from './stripe' import stripe from './stripe'
import postmark from './postmark' import postmark from './postmark'
import { managementClient } from './auth0'
import defineAbilityFor from './ability' import defineAbilityFor from './ability'
import env from '../../env' import env from '../../env'
import logger from '../lib/logger' import logger from '../lib/logger'
@ -205,6 +205,10 @@ const accountService = new AccountService(
balanceSyncStrategyFactory balanceSyncStrategyFactory
) )
// auth-user
const authUserService = new AuthUserService(logger.child({ service: 'AuthUserService' }), prisma)
// user // user
const userService = new UserService( const userService = new UserService(
@ -214,7 +218,6 @@ const userService = new UserService(
balanceSyncStrategyFactory, balanceSyncStrategyFactory,
queueService.getQueue('sync-user'), queueService.getQueue('sync-user'),
queueService.getQueue('purge-user'), queueService.getQueue('purge-user'),
managementClient,
stripe stripe
) )
@ -276,22 +279,23 @@ const stripeWebhooks = new StripeWebhookHandler(
) )
// helper function for parsing JWT and loading User record // helper function for parsing JWT and loading User record
// TODO: update this with roles, identity, and metadata
async function getCurrentUser(jwt: NonNullable<Request['user']>) { async function getCurrentUser(jwt: NonNullable<Request['user']>) {
if (!jwt.sub) throw new Error(`jwt missing sub`) if (!jwt.sub) throw new Error(`jwt missing sub`)
if (!jwt['https://maybe.co/email']) throw new Error(`jwt missing email`) if (!jwt['https://maybe.co/email']) throw new Error(`jwt missing email`)
const user = const user =
(await prisma.user.findUnique({ (await prisma.user.findUnique({
where: { auth0Id: jwt.sub }, where: { authId: jwt.sub },
})) ?? })) ??
(await prisma.user.upsert({ (await prisma.user.upsert({
where: { auth0Id: jwt.sub }, where: { authId: jwt.sub },
create: { create: {
auth0Id: jwt.sub, authId: jwt.sub,
email: jwt['https://maybe.co/email'], email: jwt['https://maybe.co/email'],
picture: jwt[SharedType.Auth0CustomNamespace.Picture], picture: jwt['picture'],
firstName: jwt[SharedType.Auth0CustomNamespace.UserMetadata]?.['firstName'], firstName: jwt['firstName'],
lastName: jwt[SharedType.Auth0CustomNamespace.UserMetadata]?.['lastName'], lastName: jwt['lastName'],
}, },
update: {}, update: {},
})) }))
@ -312,7 +316,6 @@ export async function createContext(req: Request) {
prisma, prisma,
plaid, plaid,
stripe, stripe,
managementClient,
logger, logger,
user, user,
ability: defineAbilityFor(user), ability: defineAbilityFor(user),
@ -320,6 +323,7 @@ export async function createContext(req: Request) {
transactionService, transactionService,
holdingService, holdingService,
accountConnectionService, accountConnectionService,
authUserService,
userService, userService,
valuationService, valuationService,
institutionService, institutionService,

View file

@ -3,7 +3,7 @@ import * as Sentry from '@sentry/node'
export const identifySentryUser: ErrorRequestHandler = (err, req, _res, next) => { export const identifySentryUser: ErrorRequestHandler = (err, req, _res, next) => {
Sentry.setUser({ Sentry.setUser({
auth0Id: req.user?.sub, authId: req.user?.sub,
}) })
next(err) next(err)

View file

@ -2,7 +2,7 @@ export * from './dev-only'
export * from './error-handler' export * from './error-handler'
export * from './auth-error-handler' export * from './auth-error-handler'
export * from './superjson' export * from './superjson'
export * from './validate-auth0-jwt' export * from './validate-auth-jwt'
export * from './validate-plaid-jwt' export * from './validate-plaid-jwt'
export * from './validate-finicity-signature' export * from './validate-finicity-signature'
export { default as maintenance } from './maintenance' export { default as maintenance } from './maintenance'

View file

@ -0,0 +1,33 @@
import cookieParser from 'cookie-parser'
import { decode } from 'next-auth/jwt'
const SECRET = process.env.NEXTAUTH_SECRET ?? 'REPLACE_THIS'
export const validateAuthJwt = async (req, res, next) => {
cookieParser(SECRET)(req, res, async (err) => {
if (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
if (req.cookies && 'next-auth.session-token' in req.cookies) {
try {
const token = await decode({
token: req.cookies['next-auth.session-token'],
secret: SECRET,
})
if (token) {
req.user = token
return next()
} else {
return res.status(401).json({ message: 'Unauthorized' })
}
} catch (error) {
console.error('Error in token validation', error)
return res.status(500).json({ message: 'Internal Server Error' })
}
} else {
return res.status(401).json({ message: 'Unauthorized' })
}
})
}

View file

@ -1,22 +0,0 @@
import type { GetVerificationKey } from 'express-jwt'
import { expressjwt as jwt } from 'express-jwt'
import jwks from 'jwks-rsa'
import env from '../../env'
/**
* The user will authenticate on the frontend SPA (React) via Authorization Code Flow with PKCE
* and receive an access token. This token is passed in HTTP headers and validated on the backend
* via this middleware
*/
export const validateAuth0Jwt = jwt({
requestProperty: 'user',
secret: jwks.expressJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `https://${env.NX_AUTH0_CUSTOM_DOMAIN}/.well-known/jwks.json`,
}) as GetVerificationKey,
audience: env.NX_AUTH0_AUDIENCE, // This is a unique identifier from Auth0 (not a valid URL)
issuer: `https://${env.NX_AUTH0_CUSTOM_DOMAIN}/`,
algorithms: ['RS256'],
})

View file

@ -1,82 +0,0 @@
import { Router } from 'express'
import { auth, claimCheck } from 'express-openid-connect'
import { createBullBoard } from '@bull-board/api'
import { BullAdapter } from '@bull-board/api/bullAdapter'
import { ExpressAdapter } from '@bull-board/express'
import { AuthUtil, BullQueue } from '@maybe-finance/server/shared'
import { SharedType } from '@maybe-finance/shared'
import { queueService } from '../lib/endpoint'
import env from '../../env'
const router = Router()
const serverAdapter = new ExpressAdapter().setBasePath('/admin/bullmq')
createBullBoard({
queues: queueService.allQueues
.filter((q): q is BullQueue => q instanceof BullQueue)
.map((q) => new BullAdapter(q.queue)),
serverAdapter,
})
const isProd = process.env.NODE_ENV === 'production' && process.env.IS_PULL_REQUEST !== 'true'
const prodCookieConfig = isProd
? {
session: {
cookie: {
domain: '.maybe.co',
path: '/admin',
},
},
}
: {}
// This will ensure that only Auth0 users with the "admin" role can visit these pages
router.use(
auth({
authRequired: true,
idpLogout: true, // Logout of Auth0 provider
auth0Logout: isProd, // Same as idpLogout, but for custom domain
secret: env.NX_SESSION_SECRET,
baseURL: `${env.NX_API_URL}/admin`,
clientID: env.NX_AUTH0_CLIENT_ID,
clientSecret: env.NX_AUTH0_CLIENT_SECRET,
issuerBaseURL: `https://${env.NX_AUTH0_CUSTOM_DOMAIN}`,
authorizationParams: {
response_type: 'code',
audience: env.NX_AUTH0_AUDIENCE,
scope: 'openid profile email',
},
routes: {
postLogoutRedirect: env.NX_API_URL,
},
...prodCookieConfig,
})
)
/**
* Auth0 requires all custom claims to be namespaced
* @see https://auth0.com/docs/security/tokens/json-web-tokens/create-namespaced-custom-claims
*
* This is the namespace that has been set in the "Rules" section of Maybe's Auth0 dashboard
*
* The rule used below is called "Add Roles to ID token", and will attach an array of roles
* that are assigned to an Auth0 user under the https://maybe.co/roles namespace
*
* @see https://auth0.com/docs/authorization/authorization-policies/sample-use-cases-rules-with-authorization#add-user-roles-to-tokens
*/
const adminClaimCheck = claimCheck((_req, claims) => AuthUtil.verifyRoleClaims(claims, 'Admin'))
router.get('/', adminClaimCheck, (req, res) => {
res.render('pages/dashboard', {
user: req.oidc.user?.name,
role: req.oidc.idTokenClaims?.[SharedType.Auth0CustomNamespace.Roles],
})
})
// Visit /admin/bullmq to see BullMQ Dashboard
router.use('/bullmq', adminClaimCheck, serverAdapter.getRouter())
export default router

View file

@ -47,13 +47,13 @@ router.post(
trialLapsed: z.boolean().default(false), trialLapsed: z.boolean().default(false),
}), }),
resolve: async ({ ctx, input }) => { resolve: async ({ ctx, input }) => {
ctx.logger.debug(`Resetting CI user ${ctx.user!.auth0Id}`) ctx.logger.debug(`Resetting CI user ${ctx.user!.authId}`)
await ctx.prisma.$transaction([ await ctx.prisma.$transaction([
ctx.prisma.$executeRaw`DELETE FROM "user" WHERE auth0_id=${ctx.user!.auth0Id};`, ctx.prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${ctx.user!.authId};`,
ctx.prisma.user.create({ ctx.prisma.user.create({
data: { data: {
auth0Id: ctx.user!.auth0Id, authId: ctx.user!.authId,
email: 'REPLACE_THIS', email: 'REPLACE_THIS',
dob: new Date('1990-01-01'), dob: new Date('1990-01-01'),
linkAccountDismissedAt: new Date(), // ensures our auto-account link doesn't trigger linkAccountDismissedAt: new Date(), // ensures our auto-account link doesn't trigger

View file

@ -5,7 +5,6 @@ export { default as usersRouter } from './users.router'
export { default as webhooksRouter } from './webhooks.router' export { default as webhooksRouter } from './webhooks.router'
export { default as plaidRouter } from './plaid.router' export { default as plaidRouter } from './plaid.router'
export { default as finicityRouter } from './finicity.router' export { default as finicityRouter } from './finicity.router'
export { default as adminRouter } from './admin.router'
export { default as valuationsRouter } from './valuations.router' export { default as valuationsRouter } from './valuations.router'
export { default as institutionsRouter } from './institutions.router' export { default as institutionsRouter } from './institutions.router'
export { default as transactionsRouter } from './transactions.router' export { default as transactionsRouter } from './transactions.router'

View file

@ -1,6 +1,4 @@
import { Router } from 'express' import { Router } from 'express'
import axios from 'axios'
import type { UnlinkAccountsParamsProvider } from 'auth0'
import { subject } from '@casl/ability' import { subject } from '@casl/ability'
import { z } from 'zod' import { z } from 'zod'
import { DateUtil, type SharedType } from '@maybe-finance/shared' import { DateUtil, type SharedType } from '@maybe-finance/shared'
@ -106,25 +104,10 @@ router.put(
) )
router.get( router.get(
'/auth0-profile', '/auth-profile',
endpoint.create({ endpoint.create({
resolve: async ({ ctx }) => { resolve: async ({ ctx }) => {
return ctx.userService.getAuth0Profile(ctx.user!) return ctx.userService.getAuthProfile(ctx.user!.id)
},
})
)
router.put(
'/auth0-profile',
endpoint.create({
input: z.object({
enrolled_mfa: z.boolean(),
}),
resolve: ({ input, ctx }) => {
return ctx.managementClient.updateUser(
{ id: ctx.user!.auth0Id },
{ user_metadata: { enrolled_mfa: input.enrolled_mfa } }
)
}, },
}) })
) )
@ -276,53 +259,20 @@ router.get(
}) })
) )
router.post( // TODO: Implement verification email using Postmark instead of Auth0
'/link-accounts',
endpoint.create({
input: z.object({
secondaryJWT: z.string(),
secondaryProvider: z.string(),
}),
resolve: async ({ input, ctx }) => {
return ctx.userService.linkAccounts(ctx.user!.auth0Id, input.secondaryProvider, {
token: input.secondaryJWT,
domain: env.NX_AUTH0_CUSTOM_DOMAIN,
audience: env.NX_AUTH0_AUDIENCE,
})
},
})
)
router.post(
'/unlink-account',
endpoint.create({
input: z.object({
secondaryAuth0Id: z.string(),
secondaryProvider: z.string(),
}),
resolve: async ({ input, ctx }) => {
return ctx.userService.unlinkAccounts(
ctx.user!.auth0Id,
input.secondaryAuth0Id,
input.secondaryProvider as UnlinkAccountsParamsProvider
)
},
})
)
router.post( router.post(
'/resend-verification-email', '/resend-verification-email',
endpoint.create({ endpoint.create({
input: z.object({ input: z.object({
auth0Id: z.string().optional(), authId: z.string().optional(),
}), }),
resolve: async ({ input, ctx }) => { resolve: async ({ input, ctx }) => {
const auth0Id = input.auth0Id ?? ctx.user?.auth0Id const authId = input.authId ?? ctx.user?.authId
if (!auth0Id) throw new Error('User not found') if (!authId) throw new Error('User not found')
await ctx.managementClient.sendEmailVerification({ user_id: auth0Id }) //await ctx.managementClient.sendEmailVerification({ user_id: authId })
ctx.logger.info(`Sent verification email to ${auth0Id}`) ctx.logger.info(`Sent verification email to ${authId}`)
return { success: true } return { success: true }
}, },
@ -341,52 +291,18 @@ router.put(
throw new Error('Unable to update password. No user found.') throw new Error('Unable to update password. No user found.')
} }
const user = await ctx.managementClient.getUser({ id: req.user.sub })
const { newPassword, currentPassword } = input const { newPassword, currentPassword } = input
/**
* Auth0 doesn't have a verify password endpoint on the Management API, so this is a secure way to
* verify that the old password was valid before changing it. Why they don't have this feature still? ¯\_()_/¯
*
* @see https://community.auth0.com/t/change-password-validation/8158/10
*/
try { try {
// If this succeeds, we know the old password was correct await ctx.authUserService.updatePassword(req.user.sub, currentPassword, newPassword)
await axios.post(
`https://${env.NX_AUTH0_DOMAIN}/oauth/token`,
{
grant_type: 'password',
username: user.email,
password: currentPassword,
audience: env.NX_AUTH0_AUDIENCE,
client_id: env.NX_AUTH0_CLIENT_ID,
client_secret: env.NX_AUTH0_CLIENT_SECRET,
},
{ headers: { 'content-type': 'application/json' } }
)
} catch (err) { } catch (err) {
let errMessage = 'Could not reset password' const errMessage = 'Could not reset password'
if (axios.isAxiosError(err)) {
errMessage =
err.response?.status === 401
? 'Invalid password, please try again'
: errMessage
}
// Do not log the full error here, the user's password could be in it! // Do not log the full error here, the user's password could be in it!
ctx.logger.error('Could not reset password') ctx.logger.error('Could not reset password')
return { success: false, error: errMessage } return { success: false, error: errMessage }
} }
// https://auth0.com/docs/connections/database/password-change#use-the-management-api
await ctx.managementClient.updateUser(
{ id: req.user?.sub },
{ password: newPassword, connection: 'Username-Password-Authentication' }
)
return { success: true } return { success: true }
}, },
}) })
@ -426,9 +342,8 @@ router.post(
customer: ctx.user.stripeCustomerId, customer: ctx.user.stripeCustomerId,
} }
: { : {
customer_email: ( customer_email:
await ctx.managementClient.getUser({ id: req.user.sub }) (await ctx.authUserService.get(req.user.sub)).email ?? undefined,
).email,
}), }),
}) })

View file

@ -33,15 +33,6 @@ const envSchema = z.object({
NX_NGROK_URL: z.string().default('http://localhost:4551'), NX_NGROK_URL: z.string().default('http://localhost:4551'),
// Dev doesn't have a custom domain, so replace with the original dev URL
NX_AUTH0_DOMAIN: z.string().default('REPLACE_THIS'),
NX_AUTH0_CUSTOM_DOMAIN: z.string().default('REPLACE_THIS'),
NX_AUTH0_AUDIENCE: z.string().default('https://maybe-finance-api/v1'),
NX_AUTH0_CLIENT_ID: z.string().default('REPLACE_THIS'),
NX_AUTH0_CLIENT_SECRET: z.string(),
NX_AUTH0_MGMT_CLIENT_ID: z.string().default('REPLACE_THIS'),
NX_AUTH0_MGMT_CLIENT_SECRET: z.string(),
NX_PLAID_CLIENT_ID: z.string().default('REPLACE_THIS'), NX_PLAID_CLIENT_ID: z.string().default('REPLACE_THIS'),
NX_PLAID_SECRET: z.string(), NX_PLAID_SECRET: z.string(),
NX_PLAID_ENV: z.string().default('sandbox'), NX_PLAID_ENV: z.string().default('sandbox'),

View file

@ -1,18 +0,0 @@
import type { SharedType } from '@maybe-finance/shared'
import { ManagementClient } from 'auth0'
import env from '../../env'
/**
* Management API Documentation
* - https://auth0.com/docs/api/management/v2
* - https://auth0.github.io/node-auth0/module-management.ManagementClient.html
*/
export const managementClient = new ManagementClient<
SharedType.MaybeAppMetadata,
SharedType.MaybeUserMetadata
>({
domain: env.NX_AUTH0_DOMAIN,
clientId: env.NX_AUTH0_MGMT_CLIENT_ID,
clientSecret: env.NX_AUTH0_MGMT_CLIENT_SECRET,
scope: 'read:users update:users delete:users',
})

View file

@ -28,14 +28,12 @@ import {
LoanBalanceSyncStrategy, LoanBalanceSyncStrategy,
PlaidETL, PlaidETL,
PlaidService, PlaidService,
PropertyService,
SecurityPricingProcessor, SecurityPricingProcessor,
SecurityPricingService, SecurityPricingService,
TransactionBalanceSyncStrategy, TransactionBalanceSyncStrategy,
UserProcessor, UserProcessor,
UserService, UserService,
ValuationBalanceSyncStrategy, ValuationBalanceSyncStrategy,
VehicleService,
EmailService, EmailService,
EmailProcessor, EmailProcessor,
TransactionService, TransactionService,
@ -58,7 +56,6 @@ import prisma from './prisma'
import plaid from './plaid' import plaid from './plaid'
import finicity from './finicity' import finicity from './finicity'
import postmark from './postmark' import postmark from './postmark'
import { managementClient } from './auth0'
import stripe from './stripe' import stripe from './stripe'
import env from '../../env' import env from '../../env'
import { BullQueueEventHandler, WorkerErrorHandlerService } from '../services' import { BullQueueEventHandler, WorkerErrorHandlerService } from '../services'
@ -127,10 +124,6 @@ const finicityService = new FinicityService(
env.NX_FINICITY_ENV === 'sandbox' env.NX_FINICITY_ENV === 'sandbox'
) )
const propertyService = new PropertyService(logger.child({ service: 'PropertyService' }))
const vehicleService = new VehicleService(logger.child({ service: 'VehicleService' }))
// account-connection // account-connection
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({ const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
@ -228,7 +221,6 @@ export const userService: IUserService = new UserService(
balanceSyncStrategyFactory, balanceSyncStrategyFactory,
queueService.getQueue('sync-user'), queueService.getQueue('sync-user'),
queueService.getQueue('purge-user'), queueService.getQueue('purge-user'),
managementClient,
stripe stripe
) )
@ -282,6 +274,5 @@ export const emailService: IEmailService = new EmailService(
export const emailProcessor: IEmailProcessor = new EmailProcessor( export const emailProcessor: IEmailProcessor = new EmailProcessor(
logger.child({ service: 'EmailProcessor' }), logger.child({ service: 'EmailProcessor' }),
prisma, prisma,
managementClient,
emailService emailService
) )

View file

@ -82,7 +82,7 @@ export class BullQueueEventHandler implements IBullQueueEventHandler {
} }
private async getUserFromJob(job: Job) { private async getUserFromJob(job: Job) {
let user: Pick<User, 'id' | 'auth0Id'> | undefined let user: Pick<User, 'id' | 'authId'> | undefined
try { try {
if (job.queue.name === 'sync-account' && 'accountId' in job.data) { if (job.queue.name === 'sync-account' && 'accountId' in job.data) {

View file

@ -24,25 +24,13 @@ const envSchema = z.object({
NX_POLYGON_API_KEY: z.string().default(''), NX_POLYGON_API_KEY: z.string().default(''),
NX_AUTH0_DOMAIN: z.string().default('REPLACE_THIS'),
NX_AUTH0_MGMT_CLIENT_ID: z.string().default('REPLACE_THIS'),
NX_AUTH0_MGMT_CLIENT_SECRET: z.string(),
NX_POSTMARK_FROM_ADDRESS: z.string().default('account@maybe.co'), NX_POSTMARK_FROM_ADDRESS: z.string().default('account@maybe.co'),
NX_POSTMARK_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'), NX_POSTMARK_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'),
NX_POSTMARK_API_TOKEN: z.string().default('REPLACE_THIS'), NX_POSTMARK_API_TOKEN: z.string().default('REPLACE_THIS'),
NX_STRIPE_SECRET_KEY: z NX_STRIPE_SECRET_KEY: z.string().default('sk_test_REPLACE_THIS'),
.string()
.default(
'sk_test_REPLACE_THIS'
),
NX_CDN_PRIVATE_BUCKET: z NX_CDN_PRIVATE_BUCKET: z.string().default('REPLACE_THIS'),
.string() NX_CDN_PUBLIC_BUCKET: z.string().default('REPLACE_THIS'),
.default('REPLACE_THIS'),
NX_CDN_PUBLIC_BUCKET: z
.string()
.default('REPLACE_THIS'),
}) })
const env = envSchema.parse(process.env) const env = envSchema.parse(process.env)

View file

@ -1,21 +1,16 @@
import type { SharedType } from '@maybe-finance/shared' import type { SharedType } from '@maybe-finance/shared'
import { BrowserUtil, useAccountApi, useAccountContext } from '@maybe-finance/client/shared' import { useAccountContext } from '@maybe-finance/client/shared'
import { Menu } from '@maybe-finance/design-system' import { Menu } from '@maybe-finance/design-system'
import { RiDeleteBin5Line, RiPencilLine, RiRefreshLine } from 'react-icons/ri' import { RiDeleteBin5Line, RiPencilLine } from 'react-icons/ri'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useAuth0 } from '@auth0/auth0-react'
type Props = { type Props = {
account?: SharedType.AccountDetail account?: SharedType.AccountDetail
} }
export function AccountMenu({ account }: Props) { export function AccountMenu({ account }: Props) {
const { user } = useAuth0()
const { editAccount, deleteAccount } = useAccountContext() const { editAccount, deleteAccount } = useAccountContext()
const { useSyncAccount } = useAccountApi()
const router = useRouter() const router = useRouter()
const syncAccount = useSyncAccount()
if (!account) return null if (!account) return null
@ -28,15 +23,6 @@ export function AccountMenu({ account }: Props) {
<Menu.Item icon={<RiPencilLine />} onClick={() => editAccount(account)}> <Menu.Item icon={<RiPencilLine />} onClick={() => editAccount(account)}>
Edit Edit
</Menu.Item> </Menu.Item>
{BrowserUtil.hasRole(user, 'Admin') && (
<Menu.Item
icon={<RiRefreshLine />}
destructive
onClick={() => syncAccount.mutate(account.id)}
>
Sync
</Menu.Item>
)}
{!account.accountConnectionId && ( {!account.accountConnectionId && (
<Menu.Item <Menu.Item
icon={<RiDeleteBin5Line />} icon={<RiDeleteBin5Line />}

View file

@ -4,13 +4,11 @@ export * from './holdings-list'
export * from './insights' export * from './insights'
export * from './user-billing' export * from './user-billing'
export * from './user-details' export * from './user-details'
export * from './user-notifications'
export * from './user-security' export * from './user-security'
export * from './transactions-list' export * from './transactions-list'
export * from './investment-transactions-list' export * from './investment-transactions-list'
export * from './layout' export * from './layout'
export * from './accounts-manager' export * from './accounts-manager'
export * from './user'
export * from './net-worth-insights' export * from './net-worth-insights'
export * from './data-editor' export * from './data-editor'
export * from './loan-details' export * from './loan-details'

View file

@ -19,14 +19,12 @@ import {
RiMore2Fill, RiMore2Fill,
RiPieChart2Line, RiPieChart2Line,
RiFlagLine, RiFlagLine,
RiChatPollLine,
RiArrowRightSLine, RiArrowRightSLine,
} from 'react-icons/ri' } from 'react-icons/ri'
import { Button, Tooltip } from '@maybe-finance/design-system' import { Button, Tooltip } from '@maybe-finance/design-system'
import { useAuth0 } from '@auth0/auth0-react'
import { MenuPopover } from './MenuPopover' import { MenuPopover } from './MenuPopover'
import { UpgradePrompt } from '../user-billing'
import { SidebarOnboarding } from '../onboarding' import { SidebarOnboarding } from '../onboarding'
import { useSession } from 'next-auth/react'
export interface DesktopLayoutProps { export interface DesktopLayoutProps {
sidebar: React.ReactNode sidebar: React.ReactNode
@ -95,7 +93,8 @@ export function DesktopLayout({ children, sidebar }: DesktopLayoutProps) {
const [onboardingExpanded, setOnboardingExpanded] = useState(false) const [onboardingExpanded, setOnboardingExpanded] = useState(false)
const { popoutContents, close: closePopout } = usePopoutContext() const { popoutContents, close: closePopout } = usePopoutContext()
const { user } = useAuth0() const { data: session } = useSession()
const user = session!.user
const { useOnboarding, useUpdateOnboarding } = useUserApi() const { useOnboarding, useUpdateOnboarding } = useUserApi()
const onboarding = useOnboarding('sidebar') const onboarding = useOnboarding('sidebar')
const updateOnboarding = useUpdateOnboarding() const updateOnboarding = useUpdateOnboarding()
@ -270,8 +269,8 @@ export function DesktopLayout({ children, sidebar }: DesktopLayoutProps) {
)} )}
</AnimatePresence> </AnimatePresence>
} }
name={user?.name} name={user?.name ?? ''}
email={user?.email} email={user?.email ?? ''}
> >
{sidebar} {sidebar}
</DefaultContent> </DefaultContent>

View file

@ -1,4 +1,4 @@
import { useAuth0 } from '@auth0/auth0-react' import { signOut } from 'next-auth/react'
import { Menu } from '@maybe-finance/design-system' import { Menu } from '@maybe-finance/design-system'
import type { ComponentProps } from 'react' import type { ComponentProps } from 'react'
import { import {
@ -16,8 +16,6 @@ export function MenuPopover({
placement?: ComponentProps<typeof Menu.Item>['placement'] placement?: ComponentProps<typeof Menu.Item>['placement']
isHeader: boolean isHeader: boolean
}) { }) {
const { logout } = useAuth0()
return ( return (
<Menu> <Menu>
<Menu.Button variant="icon">{icon}</Menu.Button> <Menu.Button variant="icon">{icon}</Menu.Button>
@ -31,11 +29,7 @@ export function MenuPopover({
<Menu.ItemNextLink icon={<RiDatabase2Line />} href="/data-editor"> <Menu.ItemNextLink icon={<RiDatabase2Line />} href="/data-editor">
Fix my data Fix my data
</Menu.ItemNextLink> </Menu.ItemNextLink>
<Menu.Item <Menu.Item icon={<LogoutIcon />} destructive={true} onClick={() => signOut()}>
icon={<LogoutIcon />}
destructive={true}
onClick={() => logout({ logoutParams: { returnTo: window.location.origin } })}
>
Log out Log out
</Menu.Item> </Menu.Item>
</Menu.Items> </Menu.Items>

View file

@ -1,7 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { import {
RiChatPollLine,
RiCloseLine, RiCloseLine,
RiFlagLine, RiFlagLine,
RiFolderOpenLine, RiFolderOpenLine,
@ -15,7 +14,6 @@ import Link from 'next/link'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { ProfileCircle } from '@maybe-finance/client/shared' import { ProfileCircle } from '@maybe-finance/client/shared'
import { usePopoutContext, LayoutContextProvider } from '@maybe-finance/client/shared' import { usePopoutContext, LayoutContextProvider } from '@maybe-finance/client/shared'
import { UpgradePrompt } from '../user-billing'
import classNames from 'classnames' import classNames from 'classnames'
import type { IconType } from 'react-icons' import type { IconType } from 'react-icons'

View file

@ -3,7 +3,7 @@ import { MainContentOverlay, useUserApi } from '@maybe-finance/client/shared'
import { LoadingSpinner } from '@maybe-finance/design-system' import { LoadingSpinner } from '@maybe-finance/design-system'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import type { SharedType } from '@maybe-finance/shared' import type { SharedType } from '@maybe-finance/shared'
import { useAuth0 } from '@auth0/auth0-react' import { signOut } from 'next-auth/react'
function shouldRedirect(pathname: string, data?: SharedType.OnboardingResponse) { function shouldRedirect(pathname: string, data?: SharedType.OnboardingResponse) {
if (!data) return false if (!data) return false
@ -14,7 +14,6 @@ function shouldRedirect(pathname: string, data?: SharedType.OnboardingResponse)
export function OnboardingGuard({ children }: PropsWithChildren) { export function OnboardingGuard({ children }: PropsWithChildren) {
const router = useRouter() const router = useRouter()
const { logout } = useAuth0()
const { useOnboarding } = useUserApi() const { useOnboarding } = useUserApi()
const onboarding = useOnboarding('main', { const onboarding = useOnboarding('main', {
onSuccess(data) { onSuccess(data) {
@ -29,7 +28,7 @@ export function OnboardingGuard({ children }: PropsWithChildren) {
<MainContentOverlay <MainContentOverlay
title="Unable to load onboarding" title="Unable to load onboarding"
actionText="Logout" actionText="Logout"
onAction={() => logout({ logoutParams: { returnTo: window.location.origin } })} onAction={() => signOut()}
> >
<p>Contact us if this issue persists.</p> <p>Contact us if this issue persists.</p>
</MainContentOverlay> </MainContentOverlay>

View file

@ -1,4 +1,4 @@
import { useAuth0 } from '@auth0/auth0-react' import { signOut } from 'next-auth/react'
import { ProfileCircle } from '@maybe-finance/client/shared' import { ProfileCircle } from '@maybe-finance/client/shared'
import { Button, Menu } from '@maybe-finance/design-system' import { Button, Menu } from '@maybe-finance/design-system'
import type { SharedType } from '@maybe-finance/shared' import type { SharedType } from '@maybe-finance/shared'
@ -15,8 +15,6 @@ type Props = {
} }
export function OnboardingNavbar({ steps, currentStep, onBack }: Props) { export function OnboardingNavbar({ steps, currentStep, onBack }: Props) {
const { logout } = useAuth0()
const groups = uniqBy(steps, 'group') const groups = uniqBy(steps, 'group')
.map((s) => s.group) .map((s) => s.group)
.filter((g): g is string => g != null) .filter((g): g is string => g != null)
@ -85,9 +83,7 @@ export function OnboardingNavbar({ steps, currentStep, onBack }: Props) {
<Menu.Item <Menu.Item
icon={<RiShutDownLine />} icon={<RiShutDownLine />}
destructive={true} destructive={true}
onClick={() => onClick={() => signOut()}
logout({ logoutParams: { returnTo: window.location.origin } })
}
> >
Log out Log out
</Menu.Item> </Menu.Item>

View file

@ -237,7 +237,7 @@ export function SidebarOnboarding({ onClose, onHide }: Props) {
const description = getDescriptionComponent(step.key) const description = getDescriptionComponent(step.key)
return ( return (
<Disclosure> <Disclosure key={idx}>
{({ open }) => ( {({ open }) => (
<div <div
className={classNames( className={classNames(

View file

@ -2,16 +2,15 @@ import Link from 'next/link'
import { useUserApi } from '@maybe-finance/client/shared' import { useUserApi } from '@maybe-finance/client/shared'
import { Button } from '@maybe-finance/design-system' import { Button } from '@maybe-finance/design-system'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { useAuth0 } from '@auth0/auth0-react' import { signOut } from 'next-auth/react'
export function CountryWaitlist({ country }: { country?: string }) { export function CountryWaitlist({ country }: { country?: string }) {
const { logout } = useAuth0()
const { useDelete } = useUserApi() const { useDelete } = useUserApi()
const deleteUser = useDelete({ const deleteUser = useDelete({
onSuccess() { onSuccess() {
toast.success(`Account deleted`) toast.success(`Account deleted`)
setTimeout(() => logout({ logoutParams: { returnTo: window.location.origin } }), 500) setTimeout(() => signOut(), 500)
}, },
onError() { onError() {
toast.error(`Error deleting account`) toast.error(`Error deleting account`)

View file

@ -59,6 +59,8 @@ export function AddFirstAccount({ title, onNext }: StepProps) {
loader={BrowserUtil.enhancerizerLoader} loader={BrowserUtil.enhancerizerLoader}
src={`financial-institutions/white/${src}.svg`} src={`financial-institutions/white/${src}.svg`}
alt={name} alt={name}
height={96}
width={96}
/> />
</div> </div>
))} ))}

View file

@ -8,14 +8,14 @@ import { useUserApi } from '@maybe-finance/client/shared'
import type { StepProps } from '../StepProps' import type { StepProps } from '../StepProps'
export function EmailVerification({ title, onNext }: StepProps) { export function EmailVerification({ title, onNext }: StepProps) {
const { useAuth0Profile, useResendEmailVerification } = useUserApi() const { useAuthProfile, useResendEmailVerification } = useUserApi()
const emailVerified = useRef(false) const emailVerified = useRef(false)
const profile = useAuth0Profile({ const profile = useAuthProfile({
refetchInterval: emailVerified.current ? false : 5_000, refetchInterval: emailVerified.current ? false : 5_000,
onSuccess: (data) => { onSuccess: (data) => {
if (data.email_verified) { if (data.emailVerified) {
emailVerified.current = true emailVerified.current = true
} }
}, },
@ -70,7 +70,7 @@ export function EmailVerification({ title, onNext }: StepProps) {
'linear-gradient(180deg, rgba(35, 36, 40, 0.2) 0%, rgba(68, 71, 76, 0.2) 100%)', 'linear-gradient(180deg, rgba(35, 36, 40, 0.2) 0%, rgba(68, 71, 76, 0.2) 100%)',
}} }}
> >
{profile.data?.email_verified ? ( {profile.data?.emailVerified ? (
<RiMailCheckLine className="w-6 h-6" /> <RiMailCheckLine className="w-6 h-6" />
) : ( ) : (
<RiMailSendLine className="w-6 h-6" /> <RiMailSendLine className="w-6 h-6" />
@ -78,10 +78,10 @@ export function EmailVerification({ title, onNext }: StepProps) {
</div> </div>
</div> </div>
<h3 className="mt-12 text-center"> <h3 className="mt-12 text-center">
{profile.data?.email_verified ? 'Email verified' : title} {profile.data?.emailVerified ? 'Email verified' : title}
</h3> </h3>
<div className="text-base text-center"> <div className="text-base text-center">
{profile.data?.email_verified ? ( {profile.data?.emailVerified ? (
<p className="mt-4 text-gray-50"> <p className="mt-4 text-gray-50">
You have successfully verified{' '} You have successfully verified{' '}
<span className="text-gray-25">{profile.data?.email ?? 'your email'}</span> <span className="text-gray-25">{profile.data?.email ?? 'your email'}</span>
@ -130,7 +130,7 @@ export function EmailVerification({ title, onNext }: StepProps) {
</> </>
)} )}
</div> </div>
{profile.data?.email_verified && ( {profile.data?.emailVerified && (
<Button className="mt-5" fullWidth onClick={onNext}> <Button className="mt-5" fullWidth onClick={onNext}>
Continue setup <RiArrowRightLine className="ml-2 w-5 h-5" /> Continue setup <RiArrowRightLine className="ml-2 w-5 h-5" />
</Button> </Button>

View file

@ -1,4 +1,4 @@
import { useAuth0 } from '@auth0/auth0-react' import { signOut } from 'next-auth/react'
import { MainContentOverlay, useUserApi } from '@maybe-finance/client/shared' import { MainContentOverlay, useUserApi } from '@maybe-finance/client/shared'
import { LoadingSpinner } from '@maybe-finance/design-system' import { LoadingSpinner } from '@maybe-finance/design-system'
import type { SharedType } from '@maybe-finance/shared' import type { SharedType } from '@maybe-finance/shared'
@ -22,7 +22,6 @@ function shouldRedirect(path: string, data?: SharedType.UserSubscription) {
export function SubscriberGuard({ children }: PropsWithChildren) { export function SubscriberGuard({ children }: PropsWithChildren) {
const router = useRouter() const router = useRouter()
const { logout } = useAuth0()
const { useSubscription } = useUserApi() const { useSubscription } = useUserApi()
const subscription = useSubscription() const subscription = useSubscription()
@ -31,7 +30,7 @@ export function SubscriberGuard({ children }: PropsWithChildren) {
<MainContentOverlay <MainContentOverlay
title="Unable to load subscription" title="Unable to load subscription"
actionText="Log out" actionText="Log out"
onAction={() => logout({ logoutParams: { returnTo: window.location.origin } })} onAction={() => signOut()}
> >
<p>Contact us if this issue persists.</p> <p>Contact us if this issue persists.</p>
</MainContentOverlay> </MainContentOverlay>

View file

@ -1,267 +0,0 @@
import { useAuth0 } from '@auth0/auth0-react'
import { BoxIcon, linkAuth0AccountCtx, useUserApi } from '@maybe-finance/client/shared'
import { Button, DialogV2 } from '@maybe-finance/design-system'
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { RiAppleFill, RiCheckLine, RiLink, RiLinkUnlink, RiLoader4Fill } from 'react-icons/ri'
type Props = {
secondaryProvider: string
isOpen: boolean
onClose(): void
}
const steps = ['authenticate', 'confirm', 'complete']
export function LinkAccountFlow({ secondaryProvider, isOpen, onClose }: Props) {
const queryClient = useQueryClient()
const secondaryAuth0 = useAuth0(linkAuth0AccountCtx)
const [stepIdx, setStepIdx] = useState(0)
const [error, setError] = useState<string | null>(null)
const { useLinkAccounts } = useUserApi()
const linkAccounts = useLinkAccounts({
onSettled() {
queryClient.invalidateQueries(['users', 'auth0-profile'])
},
onSuccess() {
setStepIdx((prev) => prev + 1)
},
onError(err) {
setError(
err instanceof Error ? err.message : 'Something went wrong while linking accounts'
)
},
})
const { useUpdateProfile } = useUserApi()
const updateUser = useUpdateProfile({
onSettled: () => queryClient.invalidateQueries(['users', 'auth0-profile']),
onSuccess: undefined,
})
function completeFlow() {
updateUser.mutate({ linkAccountDismissedAt: new Date() })
setError(null)
setStepIdx(0)
onClose()
}
return (
<DialogV2
open={isOpen}
className="flex flex-col items-center text-center"
onClose={completeFlow}
>
{error ? (
<LinkError onClose={completeFlow} error={error} />
) : (
(function () {
switch (steps[stepIdx]) {
case 'authenticate':
return (
<PromptStep
secondaryProvider={secondaryProvider}
onCancel={completeFlow}
onNext={async () => {
await secondaryAuth0.loginWithPopup({
authorizationParams: {
connection:
secondaryProvider === 'apple'
? 'apple'
: 'Username-Password-Authentication',
screen_hint:
secondaryProvider !== 'apple'
? 'show-form-only'
: undefined,
max_age: 0,
display: 'page',
},
})
setStepIdx((prev) => prev + 1)
}}
/>
)
case 'confirm':
return (
<ConfirmStep
onCancel={completeFlow}
onNext={async () => {
const token = await secondaryAuth0.getAccessTokenSilently()
linkAccounts.mutate({
secondaryJWT: token,
secondaryProvider,
})
}}
isLoading={linkAccounts.isLoading}
isReady={secondaryAuth0.isAuthenticated}
/>
)
case 'complete':
return <LinkComplete onClose={completeFlow} />
default:
return null
}
})()
)}
</DialogV2>
)
}
type StepProps = {
onCancel(): void
onNext(): void
}
function PromptStep({
secondaryProvider,
onCancel,
onNext,
}: StepProps & { secondaryProvider: string }) {
return (
<>
<BoxIcon icon={RiLink} />
<h4 className="text-white mt-6 mb-2">Link accounts?</h4>
<p className="mb-6 text-gray-50 text-base">
We found an {secondaryProvider === 'apple' ? 'Apple ' : ' '} account using the same
email address as this one in our system. Do you want to link it?
</p>
<div className="flex w-full gap-4">
<Button className="w-2/4" variant="secondary" onClick={onCancel}>
Close
</Button>
{secondaryProvider === 'apple' ? (
<button
onClick={onNext}
className="w-2/4 flex items-center px-4 py-2 rounded text-base bg-white text-black shadow hover:bg-gray-25 focus:bg-gray-25 focus:ring-gray-600 font-medium"
>
<RiAppleFill className="w-4 h-4 mx-2" /> Link with Apple
</button>
) : (
<Button className="w-2/4" onClick={onNext}>
Link accounts
</Button>
)}
</div>
</>
)
}
function ConfirmStep({
isLoading,
isReady,
onCancel,
onNext,
}: StepProps & { isLoading: boolean; isReady: boolean }) {
if (!isReady) {
return (
<>
<BoxIcon icon={RiLink} />
<h4 className="text-white my-6 animate-pulse">Authentication in progress...</h4>
<Button fullWidth variant="secondary" onClick={onCancel}>
Cancel
</Button>
</>
)
}
return (
<>
<BoxIcon icon={isLoading ? RiLinkUnlink : RiLink} />
<h4 className="text-white mt-6 mb-2">
{isLoading ? 'Linking accounts ...' : 'Continue linking accounts?'}
</h4>
<div className="mb-6 text-base">
{isLoading ? (
<p className="text-gray-50">
Your accounts are being linked and data is being merged. This may take a few
seconds.
</p>
) : (
<>
<p className="text-gray-50">
After linking, both logins will use the data in{' '}
<span className="text-white">the current</span> account. Any data you
have in your secondary account will be archived and no longer available
to you. If you ever wish to recover that data, you can reverse this
process by unlinking the account in your settings.{' '}
</p>
<p className="text-white mt-4">No data will be deleted.</p>
</>
)}
</div>
<div className="flex w-full gap-4">
<Button
className="w-2/4"
variant="secondary"
onClick={onCancel}
disabled={isLoading}
>
Don't link
</Button>
<Button className="w-2/4" onClick={onNext} disabled={isLoading}>
{isLoading && (
<RiLoader4Fill className="w-4 h-4 mr-2 text-gray-200 animate-spin" />
)}
{isLoading ? 'Linking...' : 'Continue'}
</Button>
</div>
</>
)
}
function LinkComplete({ onClose }: { onClose(): void }) {
return (
<>
<BoxIcon icon={RiCheckLine} variant="teal" />
<h4 className="text-white mt-6 mb-2">Accounts linked successfully!</h4>
<p className="mb-6 text-gray-50 text-base">
Your accounts have been linked and the data has been merged successfully.
</p>
<div className="flex w-full gap-4">
<Button fullWidth onClick={onClose}>
Done
</Button>
</div>
</>
)
}
function LinkError({ onClose, error }: { onClose(): void; error: string }) {
return (
<>
<BoxIcon icon={RiLink} variant="red" />
<h4 className="text-white mt-6 mb-2">Account linking failed</h4>
<p className="mb-2 text-gray-50 text-base">{error}</p>
<a className="underline text-cyan text-base mb-6" href="mailto:hello@maybe.co">
Please contact us.
</a>
<div className="flex w-full gap-4">
<Button fullWidth onClick={onClose}>
Close
</Button>
</div>
</>
)
}

View file

@ -1,16 +1,14 @@
import { useState } from 'react' import { useState } from 'react'
import { Controller, useForm } from 'react-hook-form' import { Controller, useForm } from 'react-hook-form'
import { useAuth0 } from '@auth0/auth0-react' import { signOut } from 'next-auth/react'
import classNames from 'classnames' import classNames from 'classnames'
import { AiOutlineLoading3Quarters as LoadingIcon } from 'react-icons/ai' import { AiOutlineLoading3Quarters as LoadingIcon } from 'react-icons/ai'
import { import {
RiAnticlockwise2Line, RiAnticlockwise2Line,
RiAppleFill,
RiArrowGoBackFill, RiArrowGoBackFill,
RiDownloadLine, RiDownloadLine,
RiShareForwardLine, RiShareForwardLine,
} from 'react-icons/ri' } from 'react-icons/ri'
import { UserIdentityList } from '../user-details/UserIdentityList'
import { import {
Button, Button,
DatePicker, DatePicker,
@ -30,10 +28,8 @@ import { DeleteUserButton } from './DeleteUserButton'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
export function UserDetails() { export function UserDetails() {
const { logout } = useAuth0() const { useProfile, useUpdateProfile } = useUserApi()
const { useProfile, useAuth0Profile, useUpdateProfile } = useUserApi()
const auth0ProfileQuery = useAuth0Profile()
const updateProfileQuery = useUpdateProfile() const updateProfileQuery = useUpdateProfile()
const profileQuery = useProfile() const profileQuery = useProfile()
@ -77,17 +73,7 @@ export function UserDetails() {
type="text" type="text"
/> />
</form> </form>
{auth0ProfileQuery.data?.primaryIdentity.provider === 'apple' && (
<div className="flex items-center gap-x-1 mt-2 text-gray-100">
<span className="text-sm">Apple identity</span>
<RiAppleFill className="w-3 h-3" />
</div> </div>
)}
</div>
{auth0ProfileQuery.data && (
<UserIdentityList profile={auth0ProfileQuery.data} />
)}
</LoadingPlaceholder> </LoadingPlaceholder>
</section> </section>
@ -101,9 +87,7 @@ export function UserDetails() {
Deleting your account is a permanent action. If you delete your account, you Deleting your account is a permanent action. If you delete your account, you
will no longer be able to sign and all data will be deleted. will no longer be able to sign and all data will be deleted.
</p> </p>
<DeleteUserButton <DeleteUserButton onDelete={() => signOut()} />
onDelete={() => logout({ logoutParams: { returnTo: window.location.origin } })}
/>
</section> </section>
</div> </div>
) )

View file

@ -1,93 +0,0 @@
import type { SharedType } from '@maybe-finance/shared'
import { Button } from '@maybe-finance/design-system'
import { RiAppleFill, RiMailLine, RiCheckboxCircleFill } from 'react-icons/ri'
export type UserIdentity =
| {
variant: 'primary'
provider: string
email: string
auth0Id?: string
isLinked?: boolean
}
| {
variant: 'linked'
provider: string
email: string
auth0Id: string
isLinked?: never
}
| {
variant: 'unlinked'
provider: string
email?: string // We might have an email here if we've detected a duplicate account
auth0Id?: never
isLinked?: never
}
export function UserIdentityCard({
identity,
onUnlink,
onLink,
}: {
identity: UserIdentity
onUnlink?(data: SharedType.UnlinkAccount): void
onLink?(): void
}) {
return (
<div className="flex items-center bg-gray-800 rounded-lg p-4">
<div className="flex items-center justify-center w-12">
{identity.provider === 'apple' ? (
<RiAppleFill className="w-8 h-8" />
) : (
<RiMailLine className="w-8 h-8" />
)}
</div>
<div className="ml-4 flex flex-col justify-around text-white">
<span>{identity.email ?? ''}</span>
<div className="flex items-center">
{!identity.email && (
<span className="text-gray-100">
{identity.provider === 'apple' ? 'Apple account' : 'Email account'}
</span>
)}
{identity.variant === 'primary' && (
<span className="inline-flex items-center text-sm text-cyan-400">
<RiCheckboxCircleFill className="w-4 h-4 mr-1" />
Main
</span>
)}
</div>
</div>
{identity.isLinked && (
<span className="ml-auto inline-flex items-center text-base font-medium text-teal">
<RiCheckboxCircleFill className="w-5 h-5 mr-2" />
Linked
</span>
)}
{identity.variant === 'linked' && (
<Button
variant="secondary"
className="ml-auto"
onClick={() =>
onUnlink?.({
secondaryAuth0Id: identity.auth0Id,
secondaryProvider: identity.provider,
})
}
>
Unlink
</Button>
)}
{identity.variant === 'unlinked' && (
<Button variant="secondary" className="ml-auto" onClick={onLink}>
Link
</Button>
)}
</div>
)
}

View file

@ -1,110 +0,0 @@
import type { SharedType } from '@maybe-finance/shared'
import { ConfirmDialog, useModalManager, useUserApi } from '@maybe-finance/client/shared'
import { useState } from 'react'
import { UserIdentityCard } from './UserIdentityCard'
export function UserIdentityList({ profile }: { profile: SharedType.Auth0Profile }) {
const { useUnlinkAccount } = useUserApi()
const { dispatch } = useModalManager()
const [isConfirm, setIsConfirm] = useState(false)
const [unlinkProps, setUnlinkProps] = useState<SharedType.UnlinkAccount | undefined>(undefined)
const unlinkAccountQuery = useUnlinkAccount()
const { primaryIdentity, secondaryIdentities, suggestedIdentities, email } = profile
return (
<>
<div className="mt-4 text-base">
<p className="text-gray-50 mb-2">Identities</p>
<div className="space-y-2">
{/* The user's primary account identity */}
<UserIdentityCard
key="primary"
identity={{
variant: 'primary',
provider: primaryIdentity.provider,
email: email!,
isLinked: secondaryIdentities.length > 0,
}}
/>
{/* Any identities the user has already linked */}
{secondaryIdentities.map((si) => (
<UserIdentityCard
key={si.user_id}
identity={{
variant: 'linked',
provider: si.provider,
email: si.profileData?.email ?? email!,
auth0Id: si.user_id,
}}
onUnlink={(data) => {
setUnlinkProps(data)
setIsConfirm(true)
}}
/>
))}
{/* Accounts that can be linked */}
{suggestedIdentities.map((si) => (
<UserIdentityCard
key={si.user_id}
identity={{
variant: 'unlinked',
provider: si.provider,
email,
}}
onLink={() =>
dispatch({
type: 'open',
key: 'linkAuth0Accounts',
props: { secondaryProvider: si.provider },
})
}
/>
))}
{/* If the primary is an email/password account and has no linked or suggested identities,
we can suggest they link an Apple account */}
{!primaryIdentity.isSocial &&
!secondaryIdentities.length &&
!suggestedIdentities.length && (
<UserIdentityCard
key="apple-auto-suggested"
identity={{
variant: 'unlinked',
provider: 'apple',
}}
onLink={() =>
dispatch({
type: 'open',
key: 'linkAuth0Accounts',
props: { secondaryProvider: 'apple' },
})
}
/>
)}
</div>
</div>
<ConfirmDialog
isOpen={isConfirm}
onCancel={() => setIsConfirm(false)}
onConfirm={async () => {
setIsConfirm(false)
await unlinkAccountQuery.mutateAsync(unlinkProps!)
}}
title="Unlink account?"
>
<div className="mt-4 text-base text-gray-50 space-y-2">
<p>
Unlinking this account will remove the connection permanently.{' '}
<span className="text-white">No data will be lost.</span>
</p>
<p>
After unlinking, each login will become a{' '}
<span className="text-white">separate</span> Maybe account.
</p>
</div>
</ConfirmDialog>
</>
)
}

View file

@ -1,2 +1 @@
export * from './UserDetails' export * from './UserDetails'
export * from './LinkAccountFlow'

View file

@ -1,20 +0,0 @@
import { useUserApi } from '@maybe-finance/client/shared'
export function GeneralPreferences() {
const { useProfile } = useUserApi()
const userProfile = useProfile()
if (!userProfile.data) {
return null
}
return (
<>
<h4 className="mb-2 text-lg uppercase mt-8 mb-2">Ask the advisor</h4>
{/* TODO: Update notifications or remove */}
<div className="space-y-4"></div>
</>
)
}

View file

@ -1 +0,0 @@
export * from './NotificationPreferences'

View file

@ -1,73 +0,0 @@
import type { SharedType } from '@maybe-finance/shared'
import { useAuth0 } from '@auth0/auth0-react'
import { useUserApi } from '@maybe-finance/client/shared'
import { Button } from '@maybe-finance/design-system'
import { toast } from 'react-hot-toast'
export function MultiFactorAuthentication({ enabled }: { enabled: boolean }) {
const { useUpdateAuth0Profile } = useUserApi()
const { loginWithPopup } = useAuth0<SharedType.Auth0ReactUser>()
const updateProfile = useUpdateAuth0Profile({
onSuccess(data) {
toast.success('MFA setting updated successfully')
if (data?.user_metadata?.enrolled_mfa === true) {
loginWithPopup(
{
authorizationParams: {
connection: 'Username-Password-Authentication',
screen_hint: 'show-form-only',
display: 'page',
},
},
{ timeoutInSeconds: 360 }
)
}
},
onError() {
toast.error('Something went wrong enabling MFA on this account.')
},
})
return (
<section className="mt-6">
<header className="flex items-center bg-gray-800 rounded-lg p-4">
<div className="flex items-center justify-center">
{enabled ? (
<i className="ri-lock-password-line text-4xl text-gray-50" />
) : (
<i className="ri-lock-unlock-line text-4xl text-gray-50" />
)}
</div>
<div className="ml-4 flex flex-col justify-around text-white">
<div className="flex items-center">
<span className="mr-2 font-normal">Multi-factor authentication</span>
{enabled ? (
<span className="text-sm font-medium py-0.5 px-1 text-teal bg-teal/10 rounded-sm mr-1">
Enabled
</span>
) : (
<span className="text-sm font-medium py-0.5 px-1 text-red bg-red/10 rounded-sm mr-1">
Not enabled
</span>
)}
</div>
{!enabled && (
<div className="text-gray-100 text-base">Requires authenticator app</div>
)}
</div>
<Button
variant="secondary"
className="ml-auto"
onClick={() => updateProfile.mutate({ enrolled_mfa: !enabled })}
disabled={updateProfile.isLoading}
>
{enabled ? 'Remove' : 'Set up'}
</Button>
</header>
</section>
)
}

View file

@ -1,47 +1,12 @@
import { useUserApi } from '@maybe-finance/client/shared' import { useUserApi } from '@maybe-finance/client/shared'
import { Button, LoadingSpinner } from '@maybe-finance/design-system' import { Button, LoadingSpinner } from '@maybe-finance/design-system'
import { MultiFactorAuthentication } from './MultiFactorAuthentication'
import { PasswordReset } from './PasswordReset' import { PasswordReset } from './PasswordReset'
export function SecurityPreferences() { export function SecurityPreferences() {
const { useAuth0Profile } = useUserApi()
const profileQuery = useAuth0Profile()
if (profileQuery.isLoading) {
return <LoadingSpinner />
}
if (profileQuery.isError) {
return ( return (
<p className="text-gray-50">
Something went wrong loading your security preferences...
</p>
)
}
const { socialOnlyUser, mfaEnabled } = profileQuery.data
return socialOnlyUser ? (
<>
<p className="text-base text-white">
Your account credentials are managed by Apple. To reset your password, click the
button below to go to your Apple settings.
</p>
<Button className="mt-4" href="https://appleid.apple.com/">
Manage Apple Account
</Button>
</>
) : (
<> <>
<h4 className="mb-2 text-lg uppercase">Password</h4> <h4 className="mb-2 text-lg uppercase">Password</h4>
<PasswordReset /> <PasswordReset />
<h4 className="mb-2 mt-8 text-lg uppercase">Multi-Factor Authentication</h4>
<p className="text-base text-gray-100">
Add an extra layer of security by setting up multi-factor authentication. This will
need an app like Google Authenticator or Authy.
</p>
<MultiFactorAuthentication enabled={mfaEnabled} />
</> </>
) )
} }

View file

@ -1,45 +0,0 @@
import { useAuth0 } from '@auth0/auth0-react'
import { LoadingSpinner } from '@maybe-finance/design-system'
import { SharedType } from '@maybe-finance/shared'
import { useMemo } from 'react'
import { RiInformationLine } from 'react-icons/ri'
export function AuthLoader({ message }: { message?: string }): JSX.Element {
const { user } = useAuth0()
const currentLoginType = useMemo(() => {
const primaryIdentity =
user && user[SharedType.Auth0CustomNamespace.PrimaryIdentity]?.provider
return primaryIdentity ? primaryIdentity : undefined
}, [user])
return (
<>
{message && (
<div className="fixed top-5 px-10 w-full">
<div className="flex items-center text-sm bg-gray-500 px-4 py-2 rounded">
<RiInformationLine className="w-6 h-6 mr-3 shrink-0" />
<p>
You are currently logged in to your{' '}
<span className="text-white">
{currentLoginType === 'apple' ? 'Apple ' : 'Email/Password '}
</span>{' '}
account. Please login with your{' '}
<span className="text-white">
{currentLoginType === 'apple' ? 'Email/Password ' : 'Apple '}
account
</span>
, and we'll merge the data between the two (no data will be lost).
</p>
</div>
</div>
)}
<div className="flex flex-col items-center justify-center h-screen transform -translate-y-16">
<LoadingSpinner />
<p className="mt-4 text-base text-gray-50 animate-pulse">{message || ''}</p>
</div>
</>
)
}

View file

@ -1 +0,0 @@
export * from './AuthLoader'

View file

@ -1,5 +1,6 @@
export * from './useAccountApi' export * from './useAccountApi'
export * from './useAccountConnectionApi' export * from './useAccountConnectionApi'
export * from './useAuthUserApi'
export * from './useFinicityApi' export * from './useFinicityApi'
export * from './useInstitutionApi' export * from './useInstitutionApi'
export * from './useUserApi' export * from './useUserApi'

View file

@ -0,0 +1,28 @@
import type { SharedType } from '@maybe-finance/shared'
import type { AxiosInstance } from 'axios'
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAxiosWithAuth } from '../hooks/useAxiosWithAuth'
const AuthUserApi = (axios: AxiosInstance) => ({
async getByEmail(email: string) {
const { data } = await axios.get<SharedType.AuthUser>(`/auth-users/${email}`)
return data
},
})
const staleTimes = {
user: 30_000,
}
export function useAuthUserApi() {
const { axios } = useAxiosWithAuth()
const api = useMemo(() => AuthUserApi(axios), [axios])
const useGetByEmail = (email: string) =>
useQuery(['auth-users', email], () => api.getByEmail(email), { staleTime: staleTimes.user })
return {
useGetByEmail,
}
}

View file

@ -1,20 +1,14 @@
import type { UseMutationOptions, UseQueryOptions } from '@tanstack/react-query' import type { UseMutationOptions, UseQueryOptions } from '@tanstack/react-query'
import type { SharedType } from '@maybe-finance/shared' import type { SharedType } from '@maybe-finance/shared'
import type { Auth0ContextInterface } from '@auth0/auth0-react'
import type { AxiosInstance } from 'axios' import type { AxiosInstance } from 'axios'
import Axios from 'axios'
import * as Sentry from '@sentry/react' import * as Sentry from '@sentry/react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import { useAxiosWithAuth } from '../hooks/useAxiosWithAuth' import { useAxiosWithAuth } from '../hooks/useAxiosWithAuth'
import { useAuth0 } from '@auth0/auth0-react'
const UserApi = ( const UserApi = (axios: AxiosInstance) => ({
axios: AxiosInstance,
auth0: Auth0ContextInterface<SharedType.Auth0ReactUser>
) => ({
async getNetWorthSeries(start: string, end: string) { async getNetWorthSeries(start: string, end: string) {
const { data } = await axios.get<SharedType.NetWorthTimeSeriesResponse>( const { data } = await axios.get<SharedType.NetWorthTimeSeriesResponse>(
`/users/net-worth`, `/users/net-worth`,
@ -65,16 +59,8 @@ const UserApi = (
return data return data
}, },
async getAuth0Profile() { async getAuthProfile() {
const { data } = await axios.get<SharedType.Auth0Profile>('/users/auth0-profile') const { data } = await axios.get<SharedType.AuthUser>('/users/auth-profile')
return data
},
async updateAuth0Profile(newProfile: SharedType.UpdateAuth0User) {
const { data } = await axios.put<
SharedType.Auth0User,
SharedType.ApiResponse<SharedType.Auth0User>
>('/users/auth0-profile', newProfile)
return data return data
}, },
@ -83,49 +69,6 @@ const UserApi = (
return data return data
}, },
async toggleMFA(desiredMFAState: 'enabled' | 'disabled'): Promise<{
actualMFAState: 'enabled' | 'disabled'
desiredMFAState: 'enabled' | 'disabled'
mfaRegistrationComplete: boolean
}> {
const audience = process.env.NEXT_PUBLIC_AUTH0_AUDIENCE || 'https://maybe-finance-api/v1'
await axios.put<SharedType.Auth0User, SharedType.ApiResponse<SharedType.Auth0User>>(
'/users/auth0-profile',
{
user_metadata: { enrolled_mfa: desiredMFAState === 'enabled' ? true : false },
}
)
// If the user is enabling MFA, prompt them to set it up immediately
if (desiredMFAState === 'enabled') {
await auth0.loginWithPopup(
{
authorizationParams: {
connection: 'Username-Password-Authentication',
screen_hint: 'show-form-only',
display: 'page',
audience,
},
},
{ timeoutInSeconds: 360 }
)
}
const currentIdTokenMFAState = auth0.user?.['https://maybe.co/user-metadata']?.enrolled_mfa
? 'enabled'
: 'disabled'
// If the ID token is the same as the user's intended MFA state, that means they successfully
// completed the flow. If not, they closed the popup early.
return {
actualMFAState: currentIdTokenMFAState,
desiredMFAState,
mfaRegistrationComplete:
currentIdTokenMFAState === desiredMFAState || desiredMFAState === 'disabled',
}
},
async changePassword(newPassword: SharedType.PasswordReset) { async changePassword(newPassword: SharedType.PasswordReset) {
const { data } = await axios.put< const { data } = await axios.put<
SharedType.PasswordReset, SharedType.PasswordReset,
@ -134,36 +77,10 @@ const UserApi = (
return data return data
}, },
async linkAccounts({ secondaryJWT, secondaryProvider }: SharedType.LinkAccounts) { async resendEmailVerification(authId?: string) {
try {
const { data } = await axios.post<
SharedType.LinkAccounts,
SharedType.ApiResponse<SharedType.Auth0User>
>('/users/link-accounts', { secondaryJWT, secondaryProvider })
return data
} catch (err) {
if (Axios.isAxiosError(err)) {
const message = err.response?.data?.errors?.[0]?.title
throw new Error(message ?? 'Something went wrong')
}
throw err
}
},
async unlinkAccount(unlinkData: SharedType.UnlinkAccount) {
const { data } = await axios.post<
SharedType.UnlinkAccount,
SharedType.ApiResponse<SharedType.Auth0User>
>('/users/unlink-account', unlinkData)
return data
},
async resendEmailVerification(auth0Id?: string) {
const { data } = await axios.post<{ success: boolean }>( const { data } = await axios.post<{ success: boolean }>(
'/users/resend-verification-email', '/users/resend-verification-email',
{ auth0Id } { authId }
) )
return data return data
@ -201,8 +118,7 @@ const staleTimes = {
export function useUserApi() { export function useUserApi() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { axios } = useAxiosWithAuth() const { axios } = useAxiosWithAuth()
const auth0 = useAuth0() const api = useMemo(() => UserApi(axios), [axios])
const api = useMemo(() => UserApi(axios, auth0), [axios, auth0])
const useNetWorthSeries = ( const useNetWorthSeries = (
{ start, end }: { start: string; end: string }, { start, end }: { start: string; end: string },
@ -288,21 +204,14 @@ export function useUserApi() {
...options, ...options,
}) })
const useAuth0Profile = ( const useAuthProfile = (
options?: Omit<UseQueryOptions<SharedType.Auth0Profile>, 'queryKey' | 'queryFn'> options?: Omit<
) => useQuery(['users', 'auth0-profile'], api.getAuth0Profile, options) UseQueryOptions<SharedType.AuthUser, unknown, SharedType.AuthUser, any[]>,
'queryKey' | 'queryFn'
const useUpdateAuth0Profile = (
options?: UseMutationOptions<
SharedType.Auth0User | undefined,
unknown,
SharedType.UpdateAuth0User
> >
) => ) =>
useMutation(api.updateAuth0Profile, { useQuery(['auth-profile'], api.getAuthProfile, {
onSettled() { staleTime: staleTimes.user,
queryClient.invalidateQueries(['users', 'auth0-profile'])
},
...options, ...options,
}) })
@ -323,33 +232,6 @@ export function useUserApi() {
}, },
}) })
const useLinkAccounts = (
options?: UseMutationOptions<
SharedType.Auth0User | undefined,
unknown,
SharedType.LinkAccounts
>
) => useMutation(api.linkAccounts, options)
const useUnlinkAccount = (
options?: UseMutationOptions<
SharedType.Auth0User | undefined,
unknown,
SharedType.UnlinkAccount
>
) =>
useMutation(api.unlinkAccount, {
onSuccess: () => {
toast.success('Account unlinked!')
queryClient.invalidateQueries(['users'])
},
onError: (err) => {
Sentry.captureException(err)
toast.error('Error unlinking user account')
},
...options,
})
const useResendEmailVerification = ( const useResendEmailVerification = (
options?: UseMutationOptions<{ success: boolean } | undefined, unknown, string | undefined> options?: UseMutationOptions<{ success: boolean } | undefined, unknown, string | undefined>
) => ) =>
@ -403,12 +285,9 @@ export function useUserApi() {
useCurrentNetWorth, useCurrentNetWorth,
useProfile, useProfile,
useUpdateProfile, useUpdateProfile,
useAuth0Profile, useAuthProfile,
useUpdateAuth0Profile,
useSubscription, useSubscription,
useChangePassword, useChangePassword,
useLinkAccounts,
useUnlinkAccount,
useResendEmailVerification, useResendEmailVerification,
useCreateCheckoutSession, useCreateCheckoutSession,
useCreateCustomerPortalSession, useCreateCustomerPortalSession,

View file

@ -1,34 +0,0 @@
import type { PropsWithChildren } from 'react'
import { Button, Dialog } from '@maybe-finance/design-system'
export type UnlinkAccountConfirmProps = PropsWithChildren<{
isOpen: boolean
onCancel: () => void
onConfirm: () => void
title: string
showClose?: boolean
}>
export function ConfirmDialog({
isOpen,
onCancel,
onConfirm,
title,
showClose = false,
children,
}: UnlinkAccountConfirmProps) {
return (
<Dialog isOpen={isOpen} onClose={onCancel} showCloseButton={showClose}>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Content>{children}</Dialog.Content>
<Dialog.Actions>
<Button fullWidth variant="secondary" onClick={onCancel}>
Cancel
</Button>
<Button fullWidth variant="danger" onClick={onConfirm}>
Unlink Account
</Button>
</Dialog.Actions>
</Dialog>
)
}

View file

@ -1,4 +1,4 @@
import { useAuth0 } from '@auth0/auth0-react' import { useSession } from 'next-auth/react'
import { Button, Dialog } from '@maybe-finance/design-system' import { Button, Dialog } from '@maybe-finance/design-system'
import { useState } from 'react' import { useState } from 'react'
import axios from 'axios' import axios from 'axios'
@ -12,7 +12,7 @@ export interface FeedbackDialogProps {
export function FeedbackDialog({ isOpen, onClose, notImplementedNotice }: FeedbackDialogProps) { export function FeedbackDialog({ isOpen, onClose, notImplementedNotice }: FeedbackDialogProps) {
const [feedback, setFeedback] = useState('') const [feedback, setFeedback] = useState('')
const { user } = useAuth0() const { data: session } = useSession()
return ( return (
<Dialog isOpen={isOpen} onClose={onClose}> <Dialog isOpen={isOpen} onClose={onClose}>
@ -41,10 +41,14 @@ export function FeedbackDialog({ isOpen, onClose, notImplementedNotice }: Feedba
try { try {
await axios await axios
.create({ transformRequest: [(data) => JSON.stringify(data)] }) .create({ transformRequest: [(data) => JSON.stringify(data)] })
.post('https://hooks.zapier.com/hooks/catch/10143005/buyo6na/', { .post(
comment: `**From user:** ${user?.sub}\n\n${feedback}`, process.env.NEXT_PUBLIC_ZAPIER_FEEDBACK_HOOK_URL ||
'REPLACE_THIS',
{
comment: `**From user:** ${session?.user?.email}\n\n${feedback}`,
page: `**Main app feedback**: ${window.location.href}`, page: `**Main app feedback**: ${window.location.href}`,
}) }
)
toast.success('Your feedback was submitted!') toast.success('Your feedback was submitted!')
} catch (e) { } catch (e) {

View file

@ -1,3 +1,2 @@
export * from './FeedbackDialog' export * from './FeedbackDialog'
export * from './ConfirmDialog'
export * from './NonUSDDialog' export * from './NonUSDDialog'

View file

@ -1,64 +0,0 @@
import type { Auth0ContextInterface } from '@auth0/auth0-react'
import { createContext } from 'react'
import type { PropsWithChildren } from 'react'
import { Auth0Provider, type Auth0ProviderOptions } from '@auth0/auth0-react'
import { useRouter } from 'next/router'
export const linkAuth0AccountCtx = createContext<Auth0ContextInterface | null>(
null
) as Auth0ProviderOptions['context']
/**
* Auth0 Context Provider
*
* Why 2 configs?
*
* For user account linking, we need two contexts so that when the secondary
* user is authenticated prior to linking, it doesn't log the primary user out.
*
* @see https://github.com/auth0/auth0-react/issues/425#issuecomment-1303619555
*/
export function AuthProvider({ children }: PropsWithChildren) {
const router = useRouter()
const sharedConfig: Auth0ProviderOptions = {
domain: process.env.NEXT_PUBLIC_AUTH0_DOMAIN || 'REPLACE_THIS',
clientId: process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID || 'REPLACE_THIS',
onRedirectCallback: (appState) => router.replace(appState?.returnTo || '/'),
authorizationParams: {
audience: process.env.NEXT_PUBLIC_AUTH0_AUDIENCE || 'https://maybe-finance-api/v1',
screen_hint: router.pathname === '/register' ? 'signup' : 'login',
},
}
const isBrowser = typeof window !== 'undefined'
return (
<Auth0Provider
{...sharedConfig}
useRefreshTokens // https://auth0.com/docs/security/tokens/refresh-tokens/configure-refresh-token-rotation
cacheLocation="localstorage"
authorizationParams={{
...sharedConfig.authorizationParams,
redirect_uri: isBrowser ? `${window.location.origin}?primary` : undefined,
}}
skipRedirectCallback={
isBrowser ? window.location.href.includes('?secondary') : undefined
}
>
<Auth0Provider
{...sharedConfig}
authorizationParams={{
...sharedConfig.authorizationParams,
redirect_uri: isBrowser ? `${window.location.origin}?secondary` : undefined,
}}
skipRedirectCallback={
isBrowser ? window.location.href.includes('?primary') : undefined
}
context={linkAuth0AccountCtx}
>
{children}
</Auth0Provider>
</Auth0Provider>
)
}

View file

@ -1,11 +1,8 @@
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import type { SharedType } from '@maybe-finance/shared' import type { SharedType } from '@maybe-finance/shared'
import { superjson } from '@maybe-finance/shared' import { superjson } from '@maybe-finance/shared'
import { createContext, type PropsWithChildren, useCallback, useMemo } from 'react' import { createContext, type PropsWithChildren, useMemo } from 'react'
import { useAuth0 } from '@auth0/auth0-react'
import Axios from 'axios' import Axios from 'axios'
import * as Sentry from '@sentry/react'
import { useRouter } from 'next/router'
type CreateInstanceOptions = { type CreateInstanceOptions = {
getToken?: () => Promise<string | null> getToken?: () => Promise<string | null>
@ -15,7 +12,6 @@ type CreateInstanceOptions = {
} }
export type AxiosContextValue = { export type AxiosContextValue = {
getToken: () => Promise<string | null>
defaultBaseUrl: string defaultBaseUrl: string
axios: AxiosInstance axios: AxiosInstance
createInstance: (options?: CreateInstanceOptions) => AxiosInstance createInstance: (options?: CreateInstanceOptions) => AxiosInstance
@ -71,63 +67,29 @@ function createInstance(options?: CreateInstanceOptions) {
return instance return instance
} }
/**
* Injects the Auth0 access token into every axios request
*
* @see https://github.com/auth0/auth0-react/issues/266#issuecomment-919222402
*/
export function AxiosProvider({ children }: PropsWithChildren) { export function AxiosProvider({ children }: PropsWithChildren) {
// Rather than storing access token in localStorage (insecure), we use this method to retrieve it prior to making API calls
const { getAccessTokenSilently, isAuthenticated, loginWithRedirect } = useAuth0()
const router = useRouter()
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3333' const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'
const getToken = useCallback(async () => {
if (!isAuthenticated) return null
try {
const token = await getAccessTokenSilently()
return token
} catch (err) {
const authErr =
err && typeof err === 'object' && 'error' in err && typeof err['error'] === 'string'
? err['error']
: null
const isRecoverable = authErr
? [
'mfa_required',
'consent_required',
'interaction_required',
'login_required',
].includes(authErr)
: false
if (isRecoverable) {
await loginWithRedirect({ appState: { returnTo: router.asPath } })
} else {
Sentry.captureException(err)
}
return null
}
}, [isAuthenticated, getAccessTokenSilently, loginWithRedirect, router])
// Expose a default instance with auth, superjson, headers // Expose a default instance with auth, superjson, headers
const defaultInstance = useMemo(() => { const defaultInstance = useMemo(() => {
const defaultHeaders = { 'Content-Type': 'application/json' } const defaultHeaders = {
'Content-Type': 'application/json',
'Access-Control-Allow-Credentials': true,
}
return createInstance({ return createInstance({
getToken, axiosOptions: {
axiosOptions: { baseURL: `${API_URL}/v1`, headers: defaultHeaders }, baseURL: `${API_URL}/v1`,
headers: defaultHeaders,
withCredentials: true,
},
serialize: true, serialize: true,
deserialize: true, deserialize: true,
}) })
}, [getToken, API_URL]) }, [API_URL])
return ( return (
<AxiosContext.Provider <AxiosContext.Provider
value={{ value={{
getToken,
defaultBaseUrl: `${API_URL}/v1`, defaultBaseUrl: `${API_URL}/v1`,
axios: defaultInstance, axios: defaultInstance,
createInstance, createInstance,

View file

@ -5,4 +5,3 @@ export * from './QueryProvider'
export * from './AccountContextProvider' export * from './AccountContextProvider'
export * from './UserAccountContextProvider' export * from './UserAccountContextProvider'
export * from './PopoutProvider' export * from './PopoutProvider'
export * from './AuthProvider'

View file

@ -1,7 +0,0 @@
import type { User } from '@auth0/auth0-react'
export function hasRole(user: User | null | undefined, role: 'Admin'): boolean {
if (!user) return false
const roles = user['https://maybe.co/roles']
return roles && Array.isArray(roles) && roles.includes(role)
}

View file

@ -1,7 +1,23 @@
import type { ImageLoaderProps } from 'next/legacy/image' import type { ImageLoaderProps } from 'next/legacy/image'
function isJSON(str: string): boolean {
try {
JSON.parse(str)
return true
} catch (e) {
return false
}
}
export function enhancerizerLoader({ src, width }: ImageLoaderProps): string { export function enhancerizerLoader({ src, width }: ImageLoaderProps): string {
const parsed = JSON.parse(src) as { [key: string]: string | number } let parsed: { [key: string]: string | number }
if (isJSON(src)) {
parsed = JSON.parse(src)
} else {
parsed = { src }
}
parsed.width ??= width parsed.width ??= width
parsed.height ??= width parsed.height ??= width

View file

@ -2,4 +2,3 @@ export * from './image-loaders'
export * from './browser-utils' export * from './browser-utils'
export * from './account-utils' export * from './account-utils'
export * from './form-utils' export * from './form-utils'
export * from './auth-utils'

View file

@ -1,7 +1,7 @@
import type { Dispatch, MouseEventHandler, PropsWithChildren, SetStateAction } from 'react' import type { Dispatch, MouseEventHandler, PropsWithChildren, SetStateAction } from 'react'
import type { IconType } from 'react-icons' import type { IconType } from 'react-icons'
import type { PopperProps } from 'react-popper' import type { PopperProps } from 'react-popper'
import React, { createContext, useContext, useState, useEffect } from 'react' import React, { createContext, useContext, useState, useEffect, useRef } from 'react'
import { Listbox as HeadlessListbox } from '@headlessui/react' import { Listbox as HeadlessListbox } from '@headlessui/react'
import classNames from 'classnames' import classNames from 'classnames'
import { RiArrowDownSFill, RiCheckFill } from 'react-icons/ri' import { RiArrowDownSFill, RiCheckFill } from 'react-icons/ri'
@ -166,6 +166,8 @@ function Options({
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null) const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const isOpenRef = useRef(false)
const { styles, attributes, update } = usePopper(referenceElement, popperElement, { const { styles, attributes, update } = usePopper(referenceElement, popperElement, {
placement, placement,
modifiers: [ modifiers: [
@ -180,6 +182,7 @@ function Options({
useEffect(() => { useEffect(() => {
if (isOpen && update) update() if (isOpen && update) update()
if (isOpenRef.current !== isOpen) setIsOpen(isOpenRef.current)
}, [isOpen, update]) }, [isOpen, update])
return ( return (
@ -198,7 +201,7 @@ function Options({
{...rest} {...rest}
> >
{({ open }) => { {({ open }) => {
setIsOpen(open) isOpenRef.current = open
return children return children
}} }}
</HeadlessListbox.Options> </HeadlessListbox.Options>

View file

@ -46,6 +46,8 @@ function Items({
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null) const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const isOpenRef = useRef(false)
const { styles, attributes, update } = usePopper(referenceElement?.current, popperElement, { const { styles, attributes, update } = usePopper(referenceElement?.current, popperElement, {
placement, placement,
modifiers: [ modifiers: [
@ -60,6 +62,7 @@ function Items({
useEffect(() => { useEffect(() => {
if (isOpen && update) update() if (isOpen && update) update()
if (isOpenRef.current !== isOpen) setIsOpen(isOpenRef.current)
}, [isOpen, update]) }, [isOpen, update])
return ( return (
@ -75,7 +78,7 @@ function Items({
{...rest} {...rest}
> >
{(renderProps) => { {(renderProps) => {
setIsOpen(renderProps.open) isOpenRef.current = renderProps.open
return typeof children === 'function' ? children(renderProps) : children return typeof children === 'function' ? children(renderProps) : children
}} }}
</HeadlessMenu.Items> </HeadlessMenu.Items>

View file

@ -0,0 +1,52 @@
import type { AuthUser, PrismaClient, Prisma } from '@prisma/client'
import type { Logger } from 'winston'
import bcrypt from 'bcrypt'
export interface IAuthUserService {
get(id: AuthUser['id']): Promise<AuthUser>
delete(id: AuthUser['id']): Promise<AuthUser>
}
export class AuthUserService implements IAuthUserService {
constructor(private readonly logger: Logger, private readonly prisma: PrismaClient) {}
async get(id: AuthUser['id']) {
return await this.prisma.authUser.findUniqueOrThrow({
where: { id },
})
}
async getByEmail(email: AuthUser['email']) {
if (!email) throw new Error('No email provided')
return await this.prisma.authUser.findUnique({
where: { email },
})
}
async updatePassword(id: AuthUser['id'], oldPassword: string, newPassword: string) {
const authUser = await this.get(id)
const isMatch = await bcrypt.compare(oldPassword, authUser.password!)
if (!isMatch) {
throw new Error('Could not reset password')
} else {
const hashedPassword = await bcrypt.hash(newPassword, 10)
return await this.prisma.authUser.update({
where: { id },
data: { password: hashedPassword },
})
}
}
async create(data: Prisma.AuthUserCreateInput & { firstName: string; lastName: string }) {
const authUser = await this.prisma.authUser.create({ data: { ...data } })
return authUser
}
async delete(id: AuthUser['id']) {
const authUser = await this.get(id)
this.logger.info(`Removing user ${authUser.id} from Prisma`)
const user = await this.prisma.authUser.delete({ where: { id } })
return user
}
}

View file

@ -0,0 +1 @@
export * from './auth-user.service'

View file

@ -2,7 +2,6 @@ import type { Logger } from 'winston'
import type { PrismaClient } from '@prisma/client' import type { PrismaClient } from '@prisma/client'
import type { SendEmailQueueJobData } from '@maybe-finance/server/shared' import type { SendEmailQueueJobData } from '@maybe-finance/server/shared'
import type { IEmailService } from './email.service' import type { IEmailService } from './email.service'
import type { ManagementClient } from 'auth0'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
export interface IEmailProcessor { export interface IEmailProcessor {
@ -14,7 +13,6 @@ export class EmailProcessor implements IEmailProcessor {
constructor( constructor(
private readonly logger: Logger, private readonly logger: Logger,
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly auth0: ManagementClient,
private readonly emailService: IEmailService private readonly emailService: IEmailService
) {} ) {}

View file

@ -4,6 +4,7 @@ export * from './account-balance'
export * from './email' export * from './email'
export * from './institution' export * from './institution'
export * from './security-pricing' export * from './security-pricing'
export * from './auth-user'
export * from './user' export * from './user'
export * from './valuation' export * from './valuation'
export * from './providers' export * from './providers'

View file

@ -27,7 +27,7 @@ export class StripeWebhookHandler implements IStripeWebhookHandler {
await this.prisma.user.updateMany({ await this.prisma.user.updateMany({
where: { where: {
auth0Id: session.client_reference_id, authId: session.client_reference_id,
}, },
data: { data: {
trialEnd: null, trialEnd: null,

View file

@ -1,16 +1,6 @@
import type { import type { AccountCategory, AccountType, PrismaClient, User } from '@prisma/client'
AccountCategory,
AccountType,
PrismaClient,
User,
} from '@prisma/client'
import type { Logger } from 'winston' import type { Logger } from 'winston'
import { import type { PurgeUserQueue, SyncUserQueue } from '@maybe-finance/server/shared'
AuthUtil,
type PurgeUserQueue,
type SyncUserQueue,
} from '@maybe-finance/server/shared'
import type { ManagementClient, UnlinkAccountsParamsProvider } from 'auth0'
import type Stripe from 'stripe' import type Stripe from 'stripe'
import type { IBalanceSyncStrategyFactory } from '../account-balance' import type { IBalanceSyncStrategyFactory } from '../account-balance'
import type { IAccountQueryService } from '../account' import type { IAccountQueryService } from '../account'
@ -64,7 +54,6 @@ export class UserService implements IUserService {
private readonly balanceSyncStrategyFactory: IBalanceSyncStrategyFactory, private readonly balanceSyncStrategyFactory: IBalanceSyncStrategyFactory,
private readonly syncQueue: SyncUserQueue, private readonly syncQueue: SyncUserQueue,
private readonly purgeQueue: PurgeUserQueue, private readonly purgeQueue: PurgeUserQueue,
private readonly auth0: ManagementClient,
private readonly stripe: Stripe private readonly stripe: Stripe
) {} ) {}
@ -74,46 +63,11 @@ export class UserService implements IUserService {
}) })
} }
async getAuth0Profile(user: User): Promise<SharedType.Auth0Profile> { async getAuthProfile(id: User['id']): Promise<SharedType.AuthUser> {
if (!user.email) throw new Error('No email found for user') const user = await this.get(id)
return this.prisma.authUser.findUniqueOrThrow({
const usersWithMatchingEmail = await this.auth0.getUsersByEmail(user.email) where: { id: user.authId },
const autoPromptEnabled = user.linkAccountDismissedAt == null })
const currentUser = usersWithMatchingEmail.find((u) => u.user_id === user.auth0Id)
const primaryIdentity = currentUser?.identities?.find(
(identity) => !('profileData' in identity)
)
const secondaryIdentities =
currentUser?.identities?.filter((identity) => 'profileData' in identity) ?? []
if (!currentUser || !primaryIdentity) throw new Error('Failed to get Auth0 user')
const socialOnlyUser =
primaryIdentity.isSocial && secondaryIdentities.every((i) => i.isSocial)
const suggestedIdentities = usersWithMatchingEmail
.filter(
(match) =>
match.email_verified &&
match.user_id !== user.auth0Id &&
match.identities?.at(0) != null
)
.map((user) => user.identities!.at(0)!)
// Auth0 returns 'true' (mis-typing) or true, so normalize the type here
const email_verified =
(currentUser.email_verified as unknown as string) === 'true' ||
currentUser.email_verified === true
return {
...currentUser,
email_verified,
primaryIdentity,
secondaryIdentities,
suggestedIdentities,
socialOnlyUser,
autoPromptEnabled,
mfaEnabled: currentUser.user_metadata?.enrolled_mfa === true,
}
} }
async sync(id: User['id']) { async sync(id: User['id']) {
@ -166,9 +120,10 @@ export class UserService implements IUserService {
// Delete Stripe customer, ending any active subscriptions // Delete Stripe customer, ending any active subscriptions
if (user.stripeCustomerId) await this.stripe.customers.del(user.stripeCustomerId) if (user.stripeCustomerId) await this.stripe.customers.del(user.stripeCustomerId)
// Delete user from Auth0 so that it cannot be accessed in a partially-purged state // Delete user from Auth so that it cannot be accessed in a partially-purged state
this.logger.info(`Removing user ${user.id} from Auth0 (${user.auth0Id})`) // TODO: Update this to use new Auth
await this.auth0.deleteUser({ id: user.auth0Id }) this.logger.info(`Removing user ${user.id} from Auth (${user.authId})`)
await this.prisma.authUser.delete({ where: { id: user.authId } })
await this.purgeQueue.add('purge-user', { userId: user.id }) await this.purgeQueue.add('purge-user', { userId: user.id })
@ -321,7 +276,7 @@ export class UserService implements IUserService {
const user = await this.prisma.user.findUniqueOrThrow({ const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId }, where: { id: userId },
select: { select: {
auth0Id: true, authId: true,
onboarding: true, onboarding: true,
dob: true, dob: true,
household: true, household: true,
@ -336,10 +291,12 @@ export class UserService implements IUserService {
}, },
}) })
const auth0User = await this.auth0.getUser({ id: user.auth0Id }) const authUser = await this.prisma.authUser.findUniqueOrThrow({
where: { id: user.authId },
})
// Auth0 has this mis-typed and it comes in as a 'true' string // NextAuth used DateTime for this field
const email_verified = auth0User.email_verified as unknown as string | boolean const email_verified = authUser.emailVerified === null ? false : true
const typedOnboarding = user.onboarding as OnboardingState | null const typedOnboarding = user.onboarding as OnboardingState | null
const onboardingState = typedOnboarding const onboardingState = typedOnboarding
@ -350,8 +307,8 @@ export class UserService implements IUserService {
{ {
...user, ...user,
onboarding: onboardingState, onboarding: onboardingState,
emailVerified: email_verified === true || email_verified === 'true', emailVerified: email_verified,
isAppleIdentity: auth0User.identities?.[0].provider === 'apple', isAppleIdentity: false,
}, },
onboardingState.markedComplete onboardingState.markedComplete
) )
@ -375,7 +332,7 @@ export class UserService implements IUserService {
.setTitle((_) => "Before we start, let's verify your email") .setTitle((_) => "Before we start, let's verify your email")
.addToGroup('setup') .addToGroup('setup')
.completeIf((user) => user.emailVerified) .completeIf((user) => user.emailVerified)
.excludeIf((user) => user.isAppleIdentity) // Auth0 auto-verifies Apple identities. .excludeIf((user) => user.isAppleIdentity || true) // TODO: Needs email service to send, skip for now
onboarding onboarding
.addStep('firstAccount') .addStep('firstAccount')
@ -551,45 +508,4 @@ export class UserService implements IUserService {
return onboarding return onboarding
} }
async linkAccounts(
primaryAuth0Id: User['auth0Id'],
provider: string,
secondaryJWT: { token: string; domain: string; audience: string }
) {
const validatedJWT = await AuthUtil.validateRS256JWT(
`Bearer ${secondaryJWT.token}`,
secondaryJWT.domain,
secondaryJWT.audience
)
const user = await this.prisma.user.findFirst({ where: { auth0Id: validatedJWT.auth0Id } })
if (user?.stripePriceId) {
throw new Error(
'The account you are trying to link has an active Stripe trial or subscription. We cannot link this identity at this time.'
)
}
return this.auth0.linkUsers(primaryAuth0Id, {
user_id: validatedJWT.auth0Id,
provider,
})
}
async unlinkAccounts(
primaryAuth0Id: User['auth0Id'],
secondaryAuth0Id: User['auth0Id'],
secondaryProvider: UnlinkAccountsParamsProvider
) {
const response = await this.auth0.unlinkUsers({
id: primaryAuth0Id,
provider: secondaryProvider,
user_id: secondaryAuth0Id,
})
this.logger.info(`Unlinked ${secondaryAuth0Id} from ${primaryAuth0Id}`)
return response
}
} }

View file

@ -13,7 +13,7 @@ export async function validateRS256JWT(
domain: string, domain: string,
audience: string audience: string
): Promise<{ ): Promise<{
auth0Id: string authId: string
userMetadata: SharedType.MaybeUserMetadata userMetadata: SharedType.MaybeUserMetadata
appMetadata: SharedType.MaybeAppMetadata appMetadata: SharedType.MaybeAppMetadata
}> { }> {
@ -50,7 +50,7 @@ export async function validateRS256JWT(
if (typeof payload !== 'object') return reject('payload not an object') if (typeof payload !== 'object') return reject('payload not an object')
resolve({ resolve({
auth0Id: payload.sub!, authId: payload.sub!,
appMetadata: payload[SharedType.Auth0CustomNamespace.AppMetadata] ?? {}, appMetadata: payload[SharedType.Auth0CustomNamespace.AppMetadata] ?? {},
userMetadata: payload[SharedType.Auth0CustomNamespace.UserMetadata] ?? {}, userMetadata: payload[SharedType.Auth0CustomNamespace.UserMetadata] ?? {},
}) })

View file

@ -1,5 +1,3 @@
import type { User as Auth0UserClient } from '@auth0/auth0-react'
import type { Identity, User as Auth0UserServer } from 'auth0'
import type { import type {
AccountCategory, AccountCategory,
AccountClassification, AccountClassification,
@ -7,6 +5,7 @@ import type {
Prisma, Prisma,
Security, Security,
User as PrismaUser, User as PrismaUser,
AuthUser,
} from '@prisma/client' } from '@prisma/client'
import type { Institution } from 'plaid' import type { Institution } from 'plaid'
import type { TimeSeries, TimeSeriesResponseWithDetail, Trend } from './general-types' import type { TimeSeries, TimeSeriesResponseWithDetail, Trend } from './general-types'
@ -27,6 +26,14 @@ export type UpdateUser = Partial<
} }
> >
/**
* ================================================================
* ====== Auth User ======
* ================================================================
*/
export type { AuthUser }
/** /**
* ================================================================ * ================================================================
* ====== Net Worth ====== * ====== Net Worth ======
@ -182,39 +189,11 @@ export type MaybeCustomClaims = {
[Auth0CustomNamespace.PrimaryIdentity]?: PrimaryAuth0Identity [Auth0CustomNamespace.PrimaryIdentity]?: PrimaryAuth0Identity
} }
export type Auth0ReactUser = Auth0UserClient & MaybeCustomClaims
export type Auth0User = Auth0UserServer<MaybeAppMetadata, MaybeUserMetadata>
export type Auth0Profile = Auth0User & {
primaryIdentity: Identity // actual
secondaryIdentities: Identity[] // linked
suggestedIdentities: Identity[] // potential links
autoPromptEnabled: boolean
socialOnlyUser: boolean
mfaEnabled: boolean
}
export type UpdateAuth0User = { enrolled_mfa: boolean }
export interface PasswordReset { export interface PasswordReset {
currentPassword: string currentPassword: string
newPassword: string newPassword: string
} }
export type LinkAccountStatus = {
autoPromptEnabled: boolean
suggestedUsers: Auth0User[]
}
export interface LinkAccounts {
secondaryJWT: string
secondaryProvider: string
}
export interface UnlinkAccount {
secondaryAuth0Id: string
secondaryProvider: string
}
export type UserSubscription = { export type UserSubscription = {
subscribed: boolean subscribed: boolean
trialing: boolean trialing: boolean

View file

@ -26,10 +26,6 @@
"dev:docker:reset": "docker-compose down -v --rmi all --remove-orphans && docker system prune --all --volumes && docker-compose build", "dev:docker:reset": "docker-compose down -v --rmi all --remove-orphans && docker system prune --all --volumes && docker-compose build",
"dev:circular": "npx madge --circular --extensions ts libs", "dev:circular": "npx madge --circular --extensions ts libs",
"analyze:client": "ANALYZE=true nx build client --skip-nx-cache", "analyze:client": "ANALYZE=true nx build client --skip-nx-cache",
"auth0:sync": "node auth0/sync",
"auth0:deploy": "node auth0/deploy",
"auth0:test": "auth0 test login 7MtD6RWsXKInGPrFyeEseo7Y8PXSBEiV --tenant maybe-finance-development.us.auth0.com --force",
"auth0:edit": "live-server auth0",
"tools:pages": "live-server tools/pages", "tools:pages": "live-server tools/pages",
"prepare": "husky install" "prepare": "husky install"
}, },
@ -38,8 +34,7 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@auth0/auth0-react": "^2.0.0", "@auth/prisma-adapter": "^1.0.14",
"@auth0/nextjs-auth0": "^2.0.0",
"@bull-board/express": "^4.6.4", "@bull-board/express": "^4.6.4",
"@casl/ability": "^6.3.2", "@casl/ability": "^6.3.2",
"@casl/prisma": "^1.4.1", "@casl/prisma": "^1.4.1",
@ -93,8 +88,10 @@
"auth0-deploy-cli": "^7.15.1", "auth0-deploy-cli": "^7.15.1",
"autoprefixer": "10.4.13", "autoprefixer": "10.4.13",
"axios": "^0.26.1", "axios": "^0.26.1",
"bcrypt": "^5.1.1",
"bull": "^4.10.2", "bull": "^4.10.2",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"cookie-parser": "^1.4.6",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"cors": "^2.8.5", "cors": "^2.8.5",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
@ -122,6 +119,7 @@
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"next": "13.1.1", "next": "13.1.1",
"next-auth": "^4.24.5",
"pg": "^8.8.0", "pg": "^8.8.0",
"plaid": "^12.1.0", "plaid": "^12.1.0",
"postcss": "8.4.19", "postcss": "8.4.19",
@ -195,6 +193,7 @@
"@testing-library/react": "13.4.0", "@testing-library/react": "13.4.0",
"@testing-library/user-event": "^13.2.1", "@testing-library/user-event": "^13.2.1",
"@types/auth0": "^2.35.7", "@types/auth0": "^2.35.7",
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.12", "@types/cors": "^2.8.12",
"@types/crypto-js": "^4.1.1", "@types/crypto-js": "^4.1.1",
"@types/d3-array": "^3.0.3", "@types/d3-array": "^3.0.3",

View file

@ -0,0 +1,79 @@
/*
Warnings:
- A unique constraint covering the columns `[auth_id]` on the table `user` will be added. If there are existing duplicate values, this will fail.
- Added the required column `auth_id` to the `user` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "user" ADD COLUMN "auth_id" TEXT NOT NULL;
-- CreateTable
CREATE TABLE "auth_account" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"provider_account_id" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "auth_account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "auth_user" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT,
"email_verified" TIMESTAMP(3),
"image" TEXT,
CONSTRAINT "auth_user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "auth_session" (
"id" TEXT NOT NULL,
"session_token" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "auth_session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "auth_verification_token" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "auth_account_provider_provider_account_id_key" ON "auth_account"("provider", "provider_account_id");
-- CreateIndex
CREATE UNIQUE INDEX "auth_user_email_key" ON "auth_user"("email");
-- CreateIndex
CREATE UNIQUE INDEX "auth_session_session_token_key" ON "auth_session"("session_token");
-- CreateIndex
CREATE UNIQUE INDEX "auth_verification_token_token_key" ON "auth_verification_token"("token");
-- CreateIndex
CREATE UNIQUE INDEX "auth_verification_token_identifier_token_key" ON "auth_verification_token"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "user_auth_id_key" ON "user"("auth_id");
-- AddForeignKey
ALTER TABLE "auth_account" ADD CONSTRAINT "auth_account_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth_user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "auth_session" ADD CONSTRAINT "auth_session_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth_user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "auth_user" ADD COLUMN "password" TEXT;

View file

@ -0,0 +1,11 @@
/*
Warnings:
- You are about to drop the column `auth0_id` on the `user` table. All the data in the column will be lost.
*/
-- DropIndex
DROP INDEX "user_auth0_id_key";
-- AlterTable
ALTER TABLE "user" DROP COLUMN "auth0_id";

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "auth_user" ADD COLUMN "first_name" TEXT,
ADD COLUMN "last_name" TEXT;

View file

@ -395,7 +395,7 @@ model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
auth0Id String @unique @map("auth0_id") authId String @unique @map("auth_id") // NextAuth user id
// profile // profile
email String @db.Citext email String @db.Citext
@ -560,6 +560,61 @@ model PlanMilestone {
@@map("plan_milestone") @@map("plan_milestone")
} }
// NextAuth Models
model AuthAccount {
id String @id @default(cuid())
userId String @map("user_id")
type String
provider String
providerAccountId String @map("provider_account_id")
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("auth_account")
}
model AuthUser {
id String @id @default(cuid())
name String?
firstName String? @map("first_name")
lastName String? @map("last_name")
email String? @unique
emailVerified DateTime? @map("email_verified")
password String?
image String?
accounts AuthAccount[]
sessions AuthSession[]
@@map("auth_user")
}
model AuthSession {
id String @id @default(cuid())
sessionToken String @unique @map("session_token")
userId String @map("user_id")
expires DateTime
user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("auth_session")
}
model AuthVerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("auth_verification_token")
}
enum ApprovalStatus { enum ApprovalStatus {
pending pending
approved approved

238
yarn.lock
View file

@ -9,32 +9,25 @@
dependencies: dependencies:
"@jridgewell/trace-mapping" "^0.3.0" "@jridgewell/trace-mapping" "^0.3.0"
"@auth0/auth0-react@^2.0.0": "@auth/core@0.20.0":
version "2.0.0" version "0.20.0"
resolved "https://registry.yarnpkg.com/@auth0/auth0-react/-/auth0-react-2.0.0.tgz#74e4d3662896e71dd95cca70b395715825da3b4e" resolved "https://registry.yarnpkg.com/@auth/core/-/core-0.20.0.tgz#18b706b9708973b1fd4cb6aac5ef47d89201f925"
integrity sha512-3pf41wU6ksm/6uPYAwjX5bZ7ma/K4LethibagTrKkMPuS8UatBvxLDtl3Aq52ZlJi1I+I42ckEfzWqloNxssIg== integrity sha512-04lQH58H5d/9xQ63MOTDTOC7sXWYlr/RhJ97wfFLXzll7nYyCKbkrT3ZMdzdLC5O+qt90sQDK85TAtLlcZ2WBg==
dependencies: dependencies:
"@auth0/auth0-spa-js" "^2.0.2" "@panva/hkdf" "^1.1.1"
"@types/cookie" "0.6.0"
cookie "0.6.0"
jose "^5.1.3"
oauth4webapi "^2.4.0"
preact "10.11.3"
preact-render-to-string "5.2.3"
"@auth0/auth0-spa-js@^2.0.2": "@auth/prisma-adapter@^1.0.14":
version "2.0.2" version "1.0.14"
resolved "https://registry.yarnpkg.com/@auth0/auth0-spa-js/-/auth0-spa-js-2.0.2.tgz#fe0d5eeb6f0da48c24913a07b3565d48792de6d5" resolved "https://registry.yarnpkg.com/@auth/prisma-adapter/-/prisma-adapter-1.0.14.tgz#3b46f86beec618ab5d6756fdd1b520535cf010ac"
integrity sha512-sxK9Lb6gXGImqjmWBfndA/OSNY4YLPTPwJEVuitXIOZ1p3EoqHM4zjIHvcdiYIaVo+cUfEf3l0bf8UA7Xi4tjg== integrity sha512-7urwnDT+K81SocU0SbfY/vtY/NbXgj8/AU2k6Ek8waHT/7YPLsOQnXQsTWROmolFshNVkt2kq9Z/HOVnRdHrkQ==
"@auth0/nextjs-auth0@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@auth0/nextjs-auth0/-/nextjs-auth0-2.0.0.tgz#6695c6eb0d657f4ee6e4234891d3cb50ba0893d1"
integrity sha512-LwV3AqJh0CXzzM1vUgSVKvYcZQsp3NzV4xboBCwvfzz7DIWVu1Ge1v0uQBGnzl3XDtFFQNYqVGjcPw5Wu1/L6A==
dependencies: dependencies:
"@panva/hkdf" "^1.0.2" "@auth/core" "0.20.0"
cookie "^0.5.0"
debug "^4.3.4"
http-errors "^1.8.1"
joi "^17.6.0"
jose "^4.9.2"
openid-client "^5.2.1"
tslib "^2.4.0"
url-join "^4.0.1"
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.16.7", "@babel/code-frame@^7.18.6", "@babel/code-frame@^7.5.5", "@babel/code-frame@^7.8.3": "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.16.7", "@babel/code-frame@^7.18.6", "@babel/code-frame@^7.5.5", "@babel/code-frame@^7.8.3":
version "7.18.6" version "7.18.6"
@ -1464,6 +1457,13 @@
dependencies: dependencies:
regenerator-runtime "^0.13.10" regenerator-runtime "^0.13.10"
"@babel/runtime@^7.20.13":
version "7.23.8"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.8.tgz#8ee6fe1ac47add7122902f257b8ddf55c898f650"
integrity sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/template@^7.12.7", "@babel/template@^7.16.7", "@babel/template@^7.18.6": "@babel/template@^7.12.7", "@babel/template@^7.16.7", "@babel/template@^7.18.6":
version "7.18.6" version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.6.tgz#1283f4993e00b929d6e2d3c72fdc9168a2977a31" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.6.tgz#1283f4993e00b929d6e2d3c72fdc9168a2977a31"
@ -2162,6 +2162,21 @@
resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b"
integrity sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A== integrity sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==
"@mapbox/node-pre-gyp@^1.0.11":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa"
integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==
dependencies:
detect-libc "^2.0.0"
https-proxy-agent "^5.0.0"
make-dir "^3.1.0"
node-fetch "^2.6.7"
nopt "^5.0.0"
npmlog "^5.0.1"
rimraf "^3.0.2"
semver "^7.3.5"
tar "^6.1.11"
"@mdx-js/mdx@^1.6.22": "@mdx-js/mdx@^1.6.22":
version "1.6.22" version "1.6.22"
resolved "https://registry.yarnpkg.com/@mdx-js/mdx/-/mdx-1.6.22.tgz#8a723157bf90e78f17dc0f27995398e6c731f1ba" resolved "https://registry.yarnpkg.com/@mdx-js/mdx/-/mdx-1.6.22.tgz#8a723157bf90e78f17dc0f27995398e6c731f1ba"
@ -2696,6 +2711,11 @@
resolved "https://registry.yarnpkg.com/@panva/hkdf/-/hkdf-1.0.2.tgz#bab0f09d09de9fd83628220d496627681bc440d6" resolved "https://registry.yarnpkg.com/@panva/hkdf/-/hkdf-1.0.2.tgz#bab0f09d09de9fd83628220d496627681bc440d6"
integrity sha512-MSAs9t3Go7GUkMhpKC44T58DJ5KGk2vBo+h1cqQeqlMfdGkxaVB78ZWpv9gYi/g2fa4sopag9gJsNvS8XGgWJA== integrity sha512-MSAs9t3Go7GUkMhpKC44T58DJ5KGk2vBo+h1cqQeqlMfdGkxaVB78ZWpv9gYi/g2fa4sopag9gJsNvS8XGgWJA==
"@panva/hkdf@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@panva/hkdf/-/hkdf-1.1.1.tgz#ab9cd8755d1976e72fc77a00f7655a64efe6cd5d"
integrity sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA==
"@parcel/watcher@2.0.4": "@parcel/watcher@2.0.4":
version "2.0.4" version "2.0.4"
resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.0.4.tgz#f300fef4cc38008ff4b8c29d92588eced3ce014b" resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.0.4.tgz#f300fef4cc38008ff4b8c29d92588eced3ce014b"
@ -4407,6 +4427,13 @@
dependencies: dependencies:
"@babel/types" "^7.3.0" "@babel/types" "^7.3.0"
"@types/bcrypt@^5.0.2":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@types/bcrypt/-/bcrypt-5.0.2.tgz#22fddc11945ea4fbc3655b3e8b8847cc9f811477"
integrity sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==
dependencies:
"@types/node" "*"
"@types/body-parser@*": "@types/body-parser@*":
version "1.19.2" version "1.19.2"
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
@ -4447,6 +4474,11 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@types/cookie@0.6.0":
version "0.6.0"
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5"
integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==
"@types/cors@^2.8.12": "@types/cors@^2.8.12":
version "2.8.12" version "2.8.12"
resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080" resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080"
@ -5857,6 +5889,11 @@ abab@^2.0.6:
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291"
integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==
abbrev@1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
accepts@~1.3.4: accepts@~1.3.4:
version "1.3.7" version "1.3.7"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
@ -6841,6 +6878,14 @@ bcrypt-pbkdf@^1.0.0:
dependencies: dependencies:
tweetnacl "^0.14.3" tweetnacl "^0.14.3"
bcrypt@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.1.1.tgz#0f732c6dcb4e12e5b70a25e326a72965879ba6e2"
integrity sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==
dependencies:
"@mapbox/node-pre-gyp" "^1.0.11"
node-addon-api "^5.0.0"
bcryptjs@^2.3.0: bcryptjs@^2.3.0:
version "2.4.3" version "2.4.3"
resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb" resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb"
@ -8045,11 +8090,24 @@ convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0:
dependencies: dependencies:
safe-buffer "~5.1.1" safe-buffer "~5.1.1"
cookie-parser@^1.4.6:
version "1.4.6"
resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.6.tgz#3ac3a7d35a7a03bbc7e365073a26074824214594"
integrity sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==
dependencies:
cookie "0.4.1"
cookie-signature "1.0.6"
cookie-signature@1.0.6: cookie-signature@1.0.6:
version "1.0.6" version "1.0.6"
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==
cookie@0.4.1, cookie@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
cookie@0.4.2: cookie@0.4.2:
version "0.4.2" version "0.4.2"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
@ -8060,10 +8118,10 @@ cookie@0.5.0, cookie@^0.5.0:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
cookie@^0.4.1: cookie@0.6.0:
version "0.4.1" version "0.6.0"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
cookiejar@^2.1.2: cookiejar@^2.1.2:
version "2.1.3" version "2.1.3"
@ -8894,6 +8952,11 @@ detab@2.0.4:
dependencies: dependencies:
repeat-string "^1.5.4" repeat-string "^1.5.4"
detect-libc@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.2.tgz#8ccf2ba9315350e1241b88d0ac3b0e1fbd99605d"
integrity sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==
detect-newline@^3.0.0: detect-newline@^3.0.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651"
@ -13074,16 +13137,21 @@ jose@^2.0.6:
dependencies: dependencies:
"@panva/asn1.js" "^1.0.0" "@panva/asn1.js" "^1.0.0"
jose@^4.10.0, jose@^4.9.2:
version "4.11.1"
resolved "https://registry.yarnpkg.com/jose/-/jose-4.11.1.tgz#8f7443549befe5bddcf4bae664a9cbc1a62da4fa"
integrity sha512-YRv4Tk/Wlug8qicwqFNFVEZSdbROCHRAC6qu/i0dyNKr5JQdoa2pIGoS04lLO/jXQX7Z9omoNewYIVIxqZBd9Q==
jose@^4.10.3: jose@^4.10.3:
version "4.11.0" version "4.11.0"
resolved "https://registry.yarnpkg.com/jose/-/jose-4.11.0.tgz#1c7f5c7806383d3e836434e8f49da531cb046a9d" resolved "https://registry.yarnpkg.com/jose/-/jose-4.11.0.tgz#1c7f5c7806383d3e836434e8f49da531cb046a9d"
integrity sha512-wLe+lJHeG8Xt6uEubS4x0LVjS/3kXXu9dGoj9BNnlhYq7Kts0Pbb2pvv5KiI0yaKH/eaiR0LUOBhOVo9ktd05A== integrity sha512-wLe+lJHeG8Xt6uEubS4x0LVjS/3kXXu9dGoj9BNnlhYq7Kts0Pbb2pvv5KiI0yaKH/eaiR0LUOBhOVo9ktd05A==
jose@^4.11.4, jose@^4.15.4:
version "4.15.4"
resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.4.tgz#02a9a763803e3872cf55f29ecef0dfdcc218cc03"
integrity sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==
jose@^5.1.3:
version "5.2.0"
resolved "https://registry.yarnpkg.com/jose/-/jose-5.2.0.tgz#d0ffd7f7e31253f633eefb190a930cd14a916995"
integrity sha512-oW3PCnvyrcm1HMvGTzqjxxfnEs9EoFOFWi2HsEGhlFVOXxTE3K9GKWVMFoFw06yPUqwpvEWic1BmtUZBI/tIjw==
js-string-escape@^1.0.1: js-string-escape@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef" resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef"
@ -14343,6 +14411,11 @@ minipass@^3.0.0, minipass@^3.1.1:
dependencies: dependencies:
yallist "^4.0.0" yallist "^4.0.0"
minipass@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d"
integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==
minizlib@^2.1.1: minizlib@^2.1.1:
version "2.1.2" version "2.1.2"
resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
@ -14570,6 +14643,21 @@ nested-error-stacks@^2.0.0, nested-error-stacks@^2.1.0:
resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.1.1.tgz#26c8a3cee6cc05fbcf1e333cd2fc3e003326c0b5" resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.1.1.tgz#26c8a3cee6cc05fbcf1e333cd2fc3e003326c0b5"
integrity sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw== integrity sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==
next-auth@^4.24.5:
version "4.24.5"
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-4.24.5.tgz#1fd1bfc0603c61fd2ba6fd81b976af690edbf07e"
integrity sha512-3RafV3XbfIKk6rF6GlLE4/KxjTcuMCifqrmD+98ejFq73SRoj2rmzoca8u764977lH/Q7jo6Xu6yM+Re1Mz/Og==
dependencies:
"@babel/runtime" "^7.20.13"
"@panva/hkdf" "^1.0.2"
cookie "^0.5.0"
jose "^4.11.4"
oauth "^0.9.15"
openid-client "^5.4.0"
preact "^10.6.3"
preact-render-to-string "^5.1.19"
uuid "^8.3.2"
next-tick@^1.0.0, next-tick@^1.1.0: next-tick@^1.0.0, next-tick@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb"
@ -14633,6 +14721,11 @@ node-addon-api@^3.2.1:
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161"
integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==
node-addon-api@^5.0.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762"
integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==
node-dir@^0.1.10: node-dir@^0.1.10:
version "0.1.17" version "0.1.17"
resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5" resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5"
@ -14701,6 +14794,13 @@ node-releases@^2.0.2:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.2.tgz#7139fe71e2f4f11b47d4d2986aaf8c48699e0c01" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.2.tgz#7139fe71e2f4f11b47d4d2986aaf8c48699e0c01"
integrity sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg== integrity sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==
nopt@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88"
integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==
dependencies:
abbrev "1"
normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.5.0: normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.5.0:
version "2.5.0" version "2.5.0"
resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
@ -14815,6 +14915,16 @@ nx@15.5.2:
yargs "^17.6.2" yargs "^17.6.2"
yargs-parser "21.1.1" yargs-parser "21.1.1"
oauth4webapi@^2.4.0:
version "2.6.0"
resolved "https://registry.yarnpkg.com/oauth4webapi/-/oauth4webapi-2.6.0.tgz#776a2eb5ca6ad5e5249c4bb6194516318406d254"
integrity sha512-4P43og0d8fQ61RMQEl9L7zwGVduuYbLED7uP99MkFSGuOUvJL1Fs52/D3tRtKoFtiSwKblScTYJI+utQn3SUDg==
oauth@^0.9.15:
version "0.9.15"
resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1"
integrity sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==
object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.1, object-assign@latest: object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.1, object-assign@latest:
version "4.1.1" version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
@ -14829,7 +14939,7 @@ object-copy@^0.1.0:
define-property "^0.2.5" define-property "^0.2.5"
kind-of "^3.0.3" kind-of "^3.0.3"
object-hash@^2.0.1: object-hash@^2.0.1, object-hash@^2.2.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5" resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5"
integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw== integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==
@ -14988,6 +15098,11 @@ oidc-token-hash@^5.0.1:
resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz#ae6beec3ec20f0fd885e5400d175191d6e2f10c6" resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz#ae6beec3ec20f0fd885e5400d175191d6e2f10c6"
integrity sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ== integrity sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ==
oidc-token-hash@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz#9a229f0a1ce9d4fc89bcaee5478c97a889e7b7b6"
integrity sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==
on-finished@2.4.1: on-finished@2.4.1:
version "2.4.1" version "2.4.1"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
@ -15063,15 +15178,15 @@ openid-client@^4.9.1:
object-hash "^2.0.1" object-hash "^2.0.1"
oidc-token-hash "^5.0.1" oidc-token-hash "^5.0.1"
openid-client@^5.2.1: openid-client@^5.4.0:
version "5.3.1" version "5.6.4"
resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.3.1.tgz#69a5fa7d2b5ad479032f576852d40b4d4435488a" resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.6.4.tgz#b2c25e6d5338ba3ce00e04341bb286798a196177"
integrity sha512-RLfehQiHch9N6tRWNx68cicf3b1WR0x74bJWHRc25uYIbSRwjxYcTFaRnzbbpls5jroLAaB/bFIodTgA5LJMvw== integrity sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA==
dependencies: dependencies:
jose "^4.10.0" jose "^4.15.4"
lru-cache "^6.0.0" lru-cache "^6.0.0"
object-hash "^2.0.1" object-hash "^2.2.0"
oidc-token-hash "^5.0.1" oidc-token-hash "^5.0.3"
opn@latest: opn@latest:
version "6.0.0" version "6.0.0"
@ -16143,11 +16258,30 @@ postmark@^3.0.14:
dependencies: dependencies:
axios "^0.25.0" axios "^0.25.0"
preact@^10.5.13: preact-render-to-string@5.2.3:
version "5.2.3"
resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz#23d17376182af720b1060d5a4099843c7fe92fe4"
integrity sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==
dependencies:
pretty-format "^3.8.0"
preact-render-to-string@^5.1.19:
version "5.2.6"
resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz#0ff0c86cd118d30affb825193f18e92bd59d0604"
integrity sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==
dependencies:
pretty-format "^3.8.0"
preact@10.11.3, preact@^10.5.13:
version "10.11.3" version "10.11.3"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.11.3.tgz#8a7e4ba19d3992c488b0785afcc0f8aa13c78d19" resolved "https://registry.yarnpkg.com/preact/-/preact-10.11.3.tgz#8a7e4ba19d3992c488b0785afcc0f8aa13c78d19"
integrity sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg== integrity sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==
preact@^10.6.3:
version "10.19.3"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.19.3.tgz#7a7107ed2598a60676c943709ea3efb8aaafa899"
integrity sha512-nHHTeFVBTHRGxJXKkKu5hT8C/YWBkPso4/Gad6xuj5dbptt9iF9NZr9pHbPhBrnT2klheu7mHTxTZ/LjwJiEiQ==
prelude-ls@^1.2.1: prelude-ls@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
@ -16217,6 +16351,11 @@ pretty-format@^28.1.1, pretty-format@^28.1.3:
ansi-styles "^5.0.0" ansi-styles "^5.0.0"
react-is "^18.0.0" react-is "^18.0.0"
pretty-format@^3.8.0:
version "3.8.0"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-3.8.0.tgz#bfbed56d5e9a776645f4b1ff7aa1a3ac4fa3c385"
integrity sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==
pretty-hrtime@^1.0.3: pretty-hrtime@^1.0.3:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
@ -16972,6 +17111,11 @@ regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.7:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
regenerator-runtime@^0.14.0:
version "0.14.1"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f"
integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==
regenerator-transform@^0.15.0: regenerator-transform@^0.15.0:
version "0.15.0" version "0.15.0"
resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.0.tgz#cbd9ead5d77fae1a48d957cf889ad0586adb6537" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.0.tgz#cbd9ead5d77fae1a48d957cf889ad0586adb6537"
@ -18773,6 +18917,18 @@ tar@^6.0.2:
mkdirp "^1.0.3" mkdirp "^1.0.3"
yallist "^4.0.0" yallist "^4.0.0"
tar@^6.1.11:
version "6.2.0"
resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.0.tgz#b14ce49a79cb1cd23bc9b016302dea5474493f73"
integrity sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==
dependencies:
chownr "^2.0.0"
fs-minipass "^2.0.0"
minipass "^5.0.0"
minizlib "^2.1.1"
mkdirp "^1.0.3"
yallist "^4.0.0"
telejson@^6.0.8: telejson@^6.0.8:
version "6.0.8" version "6.0.8"
resolved "https://registry.yarnpkg.com/telejson/-/telejson-6.0.8.tgz#1c432db7e7a9212c1fbd941c3e5174ec385148f7" resolved "https://registry.yarnpkg.com/telejson/-/telejson-6.0.8.tgz#1c432db7e7a9212c1fbd941c3e5174ec385148f7"