1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-08 23:15:24 +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
NGROK_AUTH_TOKEN=
# Required for Auth0 deploy client (see `yarn auth0:deploy` command)
AUTH0_ENV=development
NX_AUTH0_MGMT_CLIENT_SECRET=
NX_AUTH0_CLIENT_SECRET=
AUTH0_DEPLOY_CLIENT_SECRET=
POSTMARK_SMTP_PASS=
NX_SESSION_SECRET=
# Generate a new secret using openssl rand -base64 32
NEXTAUTH_SECRET=
NEXTAUTH_URL=http://localhost:4200
NX_NEXTAUTH_URL=http://localhost:4200
NX_PLAID_SECRET=
NX_FINICITY_APP_KEY=
NX_FINICITY_PARTNER_SECRET=
NX_CONVERTKIT_SECRET=
NEXT_PUBLIC_ZAPIER_FEEDBACK_HOOK_URL=

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.
First, copy the `.env.example` file to `.env`:
```
cp .env.example .env
```
Then, create a new secret using `openssl rand -base64 32` and populate `NEXTAUTH_SECRET` in your `.env` file with it.
Then run the following yarn commands:
```
yarn install
yarn run dev:services
yarn prisma:migrate:dev

View file

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

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

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 { ErrorBoundary } from 'react-error-boundary'
import { Analytics } from '@vercel/analytics/react'
@ -8,18 +8,17 @@ import {
ErrorFallback,
LogProvider,
UserAccountContextProvider,
AuthProvider,
} from '@maybe-finance/client/shared'
import { AccountsManager } from '@maybe-finance/client/features'
import { AccountsManager, OnboardingGuard } from '@maybe-finance/client/features'
import { AccountContextProvider } from '@maybe-finance/client/shared'
import * as Sentry from '@sentry/react'
import { BrowserTracing } from '@sentry/tracing'
import env from '../env'
import '../styles.css'
import { withAuthenticationRequired } from '@auth0/auth0-react'
import ModalManager from '../components/ModalManager'
import { SessionProvider, useSession } from 'next-auth/react'
import Meta from '../components/Meta'
import APM from '../components/APM'
import { useRouter } from 'next/router'
Sentry.init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
@ -33,20 +32,30 @@ Sentry.init({
})
// Providers and components only relevant to a logged-in user
const WithAuth = withAuthenticationRequired(function ({ children }: PropsWithChildren) {
const WithAuth = function ({ children }: PropsWithChildren) {
const { data: session } = useSession()
const router = useRouter()
useEffect(() => {
if (!session) {
router.push('/login')
}
}, [session, router])
if (session) {
return (
<ModalManager>
<OnboardingGuard>
<UserAccountContextProvider>
<AccountContextProvider>
{children}
{/* Add, edit, delete connections and manual accounts */}
<AccountsManager />
</AccountContextProvider>
</UserAccountContextProvider>
</ModalManager>
</OnboardingGuard>
)
})
}
return null
}
export default function App({
Component: Page,
@ -71,7 +80,7 @@ export default function App({
<Meta />
<Analytics />
<QueryProvider>
<AuthProvider>
<SessionProvider>
<AxiosProvider>
<>
<APM />
@ -82,7 +91,7 @@ export default function App({
)}
</>
</AxiosProvider>
</AuthProvider>
</SessionProvider>
</QueryProvider>
</ErrorBoundary>
</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 { LoadingSpinner } from '@maybe-finance/design-system'
import { useState, type ReactElement } from 'react'
import { FullPageLayout } from '@maybe-finance/client/features'
import { Input, InputPassword, Button } from '@maybe-finance/design-system'
import { signIn, useSession } from 'next-auth/react'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import Script from 'next/script'
import Link from 'next/link'
export default function LoginPage() {
const { isAuthenticated } = useAuth0()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isValid, setIsValid] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const { data: session } = useSession()
const router = useRouter()
useEffect(() => {
if (isAuthenticated) router.push('/')
}, [isAuthenticated, router])
if (session) {
router.push('/')
}
}, [session, router])
const onSubmit = async (e: React.FormEvent<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 (
<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>
) : 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 { LoadingSpinner } from '@maybe-finance/design-system'
import { useState, type ReactElement } from 'react'
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 { useEffect } from 'react'
import Script from 'next/script'
import Link from 'next/link'
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()
useEffect(() => {
if (isAuthenticated) router.push('/')
}, [isAuthenticated, router])
if (session) router.push('/')
}, [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 (
<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>
) : 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 {
AccountSidebar,
BillingPreferences,
GeneralPreferences,
SecurityPreferences,
UserDetails,
WithSidebarLayout,
@ -35,7 +34,6 @@ export default function SettingsPage() {
>
<Tab.List>
<Tab>Details</Tab>
<Tab>Notifications</Tab>
<Tab>Security</Tab>
<Tab>Billing</Tab>
</Tab.List>
@ -43,11 +41,6 @@ export default function SettingsPage() {
<Tab.Panel>
<UserDetails />
</Tab.Panel>
<Tab.Panel>
<div className="mt-6 max-w-lg text-base">
<GeneralPreferences />
</div>
</Tab.Panel>
<Tab.Panel>
<div className="mt-6 max-w-lg">
<SecurityPreferences />

View file

@ -145,3 +145,20 @@
height: 0;
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 { PgService } from '@maybe-finance/server/shared'
import { AccountQueryService, UserService } from '@maybe-finance/server/features'
import { managementClient } from '../lib/auth0'
import { resetUser } from './utils/user'
jest.mock('plaid')
jest.mock('auth0')
@ -38,7 +37,6 @@ describe('user net worth', () => {
},
{} as any,
{} as any,
managementClient,
{} as any
)

View file

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

View file

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

View file

@ -19,7 +19,7 @@ import logger from './lib/logger'
import prisma from './lib/prisma'
import {
defaultErrorHandler,
validateAuth0Jwt,
validateAuthJwt,
superjson,
authErrorHandler,
maintenance,
@ -30,7 +30,6 @@ import {
usersRouter,
accountsRouter,
connectionsRouter,
adminRouter,
webhooksRouter,
plaidRouter,
accountRollupRouter,
@ -88,11 +87,10 @@ app.use(express.static(__dirname + '/assets'))
const origin = [env.NX_CLIENT_URL, ...env.NX_CORS_ORIGINS]
logger.info(`CORS origins: ${origin}`)
app.use(cors({ origin }))
app.use(cors({ origin, credentials: true }))
app.options('*', cors() as RequestHandler)
app.set('view engine', 'ejs').set('views', __dirname + '/app/admin/views')
app.use('/admin', adminRouter)
app.use(
morgan(env.NX_MORGAN_LOG_LEVEL, {
@ -116,7 +114,7 @@ app.use(express.json({ limit: '50mb' })) // Finicity sends large response bodies
app.use(
'/trpc',
validateAuth0Jwt,
validateAuthJwt,
trpcExpress.createExpressMiddleware({
router: appRouter,
createContext: createTRPCContext,
@ -150,7 +148,7 @@ app.use('/v1', webhooksRouter)
app.use('/v1', publicRouter)
// All routes AFTER this line are protected via OAuth
app.use('/v1', validateAuth0Jwt)
app.use('/v1', validateAuthJwt)
// Private routes
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 {
AccountService,
AccountConnectionService,
AuthUserService,
UserService,
EmailService,
AccountQueryService,
@ -56,7 +57,6 @@ import plaid, { getPlaidWebhookUrl } from './plaid'
import finicity, { getFinicityTxPushUrl, getFinicityWebhookUrl } from './finicity'
import stripe from './stripe'
import postmark from './postmark'
import { managementClient } from './auth0'
import defineAbilityFor from './ability'
import env from '../../env'
import logger from '../lib/logger'
@ -205,6 +205,10 @@ const accountService = new AccountService(
balanceSyncStrategyFactory
)
// auth-user
const authUserService = new AuthUserService(logger.child({ service: 'AuthUserService' }), prisma)
// user
const userService = new UserService(
@ -214,7 +218,6 @@ const userService = new UserService(
balanceSyncStrategyFactory,
queueService.getQueue('sync-user'),
queueService.getQueue('purge-user'),
managementClient,
stripe
)
@ -276,22 +279,23 @@ const stripeWebhooks = new StripeWebhookHandler(
)
// helper function for parsing JWT and loading User record
// TODO: update this with roles, identity, and metadata
async function getCurrentUser(jwt: NonNullable<Request['user']>) {
if (!jwt.sub) throw new Error(`jwt missing sub`)
if (!jwt['https://maybe.co/email']) throw new Error(`jwt missing email`)
const user =
(await prisma.user.findUnique({
where: { auth0Id: jwt.sub },
where: { authId: jwt.sub },
})) ??
(await prisma.user.upsert({
where: { auth0Id: jwt.sub },
where: { authId: jwt.sub },
create: {
auth0Id: jwt.sub,
authId: jwt.sub,
email: jwt['https://maybe.co/email'],
picture: jwt[SharedType.Auth0CustomNamespace.Picture],
firstName: jwt[SharedType.Auth0CustomNamespace.UserMetadata]?.['firstName'],
lastName: jwt[SharedType.Auth0CustomNamespace.UserMetadata]?.['lastName'],
picture: jwt['picture'],
firstName: jwt['firstName'],
lastName: jwt['lastName'],
},
update: {},
}))
@ -312,7 +316,6 @@ export async function createContext(req: Request) {
prisma,
plaid,
stripe,
managementClient,
logger,
user,
ability: defineAbilityFor(user),
@ -320,6 +323,7 @@ export async function createContext(req: Request) {
transactionService,
holdingService,
accountConnectionService,
authUserService,
userService,
valuationService,
institutionService,

View file

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

View file

@ -2,7 +2,7 @@ export * from './dev-only'
export * from './error-handler'
export * from './auth-error-handler'
export * from './superjson'
export * from './validate-auth0-jwt'
export * from './validate-auth-jwt'
export * from './validate-plaid-jwt'
export * from './validate-finicity-signature'
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),
}),
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([
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({
data: {
auth0Id: ctx.user!.auth0Id,
authId: ctx.user!.authId,
email: 'REPLACE_THIS',
dob: new Date('1990-01-01'),
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 plaidRouter } from './plaid.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 institutionsRouter } from './institutions.router'
export { default as transactionsRouter } from './transactions.router'

View file

@ -1,6 +1,4 @@
import { Router } from 'express'
import axios from 'axios'
import type { UnlinkAccountsParamsProvider } from 'auth0'
import { subject } from '@casl/ability'
import { z } from 'zod'
import { DateUtil, type SharedType } from '@maybe-finance/shared'
@ -106,25 +104,10 @@ router.put(
)
router.get(
'/auth0-profile',
'/auth-profile',
endpoint.create({
resolve: async ({ ctx }) => {
return ctx.userService.getAuth0Profile(ctx.user!)
},
})
)
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 } }
)
return ctx.userService.getAuthProfile(ctx.user!.id)
},
})
)
@ -276,53 +259,20 @@ router.get(
})
)
router.post(
'/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
)
},
})
)
// TODO: Implement verification email using Postmark instead of Auth0
router.post(
'/resend-verification-email',
endpoint.create({
input: z.object({
auth0Id: z.string().optional(),
authId: z.string().optional(),
}),
resolve: async ({ input, ctx }) => {
const auth0Id = input.auth0Id ?? ctx.user?.auth0Id
if (!auth0Id) throw new Error('User not found')
const authId = input.authId ?? ctx.user?.authId
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 }
},
@ -341,52 +291,18 @@ router.put(
throw new Error('Unable to update password. No user found.')
}
const user = await ctx.managementClient.getUser({ id: req.user.sub })
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 {
// If this succeeds, we know the old password was correct
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' } }
)
await ctx.authUserService.updatePassword(req.user.sub, currentPassword, newPassword)
} catch (err) {
let errMessage = 'Could not reset password'
if (axios.isAxiosError(err)) {
errMessage =
err.response?.status === 401
? 'Invalid password, please try again'
: errMessage
}
const errMessage = 'Could not reset password'
// Do not log the full error here, the user's password could be in it!
ctx.logger.error('Could not reset password')
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 }
},
})
@ -426,9 +342,8 @@ router.post(
customer: ctx.user.stripeCustomerId,
}
: {
customer_email: (
await ctx.managementClient.getUser({ id: req.user.sub })
).email,
customer_email:
(await ctx.authUserService.get(req.user.sub)).email ?? undefined,
}),
})

View file

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

View file

@ -82,7 +82,7 @@ export class BullQueueEventHandler implements IBullQueueEventHandler {
}
private async getUserFromJob(job: Job) {
let user: Pick<User, 'id' | 'auth0Id'> | undefined
let user: Pick<User, 'id' | 'authId'> | undefined
try {
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_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_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'),
NX_POSTMARK_API_TOKEN: z.string().default('REPLACE_THIS'),
NX_STRIPE_SECRET_KEY: z
.string()
.default(
'sk_test_REPLACE_THIS'
),
NX_STRIPE_SECRET_KEY: z.string().default('sk_test_REPLACE_THIS'),
NX_CDN_PRIVATE_BUCKET: z
.string()
.default('REPLACE_THIS'),
NX_CDN_PUBLIC_BUCKET: z
.string()
.default('REPLACE_THIS'),
NX_CDN_PRIVATE_BUCKET: z.string().default('REPLACE_THIS'),
NX_CDN_PUBLIC_BUCKET: z.string().default('REPLACE_THIS'),
})
const env = envSchema.parse(process.env)

View file

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

View file

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

View file

@ -19,14 +19,12 @@ import {
RiMore2Fill,
RiPieChart2Line,
RiFlagLine,
RiChatPollLine,
RiArrowRightSLine,
} from 'react-icons/ri'
import { Button, Tooltip } from '@maybe-finance/design-system'
import { useAuth0 } from '@auth0/auth0-react'
import { MenuPopover } from './MenuPopover'
import { UpgradePrompt } from '../user-billing'
import { SidebarOnboarding } from '../onboarding'
import { useSession } from 'next-auth/react'
export interface DesktopLayoutProps {
sidebar: React.ReactNode
@ -95,7 +93,8 @@ export function DesktopLayout({ children, sidebar }: DesktopLayoutProps) {
const [onboardingExpanded, setOnboardingExpanded] = useState(false)
const { popoutContents, close: closePopout } = usePopoutContext()
const { user } = useAuth0()
const { data: session } = useSession()
const user = session!.user
const { useOnboarding, useUpdateOnboarding } = useUserApi()
const onboarding = useOnboarding('sidebar')
const updateOnboarding = useUpdateOnboarding()
@ -270,8 +269,8 @@ export function DesktopLayout({ children, sidebar }: DesktopLayoutProps) {
)}
</AnimatePresence>
}
name={user?.name}
email={user?.email}
name={user?.name ?? ''}
email={user?.email ?? ''}
>
{sidebar}
</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 type { ComponentProps } from 'react'
import {
@ -16,8 +16,6 @@ export function MenuPopover({
placement?: ComponentProps<typeof Menu.Item>['placement']
isHeader: boolean
}) {
const { logout } = useAuth0()
return (
<Menu>
<Menu.Button variant="icon">{icon}</Menu.Button>
@ -31,11 +29,7 @@ export function MenuPopover({
<Menu.ItemNextLink icon={<RiDatabase2Line />} href="/data-editor">
Fix my data
</Menu.ItemNextLink>
<Menu.Item
icon={<LogoutIcon />}
destructive={true}
onClick={() => logout({ logoutParams: { returnTo: window.location.origin } })}
>
<Menu.Item icon={<LogoutIcon />} destructive={true} onClick={() => signOut()}>
Log out
</Menu.Item>
</Menu.Items>

View file

@ -1,7 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { motion } from 'framer-motion'
import {
RiChatPollLine,
RiCloseLine,
RiFlagLine,
RiFolderOpenLine,
@ -15,7 +14,6 @@ import Link from 'next/link'
import { useRouter } from 'next/router'
import { ProfileCircle } from '@maybe-finance/client/shared'
import { usePopoutContext, LayoutContextProvider } from '@maybe-finance/client/shared'
import { UpgradePrompt } from '../user-billing'
import classNames from 'classnames'
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 { useRouter } from 'next/router'
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) {
if (!data) return false
@ -14,7 +14,6 @@ function shouldRedirect(pathname: string, data?: SharedType.OnboardingResponse)
export function OnboardingGuard({ children }: PropsWithChildren) {
const router = useRouter()
const { logout } = useAuth0()
const { useOnboarding } = useUserApi()
const onboarding = useOnboarding('main', {
onSuccess(data) {
@ -29,7 +28,7 @@ export function OnboardingGuard({ children }: PropsWithChildren) {
<MainContentOverlay
title="Unable to load onboarding"
actionText="Logout"
onAction={() => logout({ logoutParams: { returnTo: window.location.origin } })}
onAction={() => signOut()}
>
<p>Contact us if this issue persists.</p>
</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 { Button, Menu } from '@maybe-finance/design-system'
import type { SharedType } from '@maybe-finance/shared'
@ -15,8 +15,6 @@ type Props = {
}
export function OnboardingNavbar({ steps, currentStep, onBack }: Props) {
const { logout } = useAuth0()
const groups = uniqBy(steps, 'group')
.map((s) => s.group)
.filter((g): g is string => g != null)
@ -85,9 +83,7 @@ export function OnboardingNavbar({ steps, currentStep, onBack }: Props) {
<Menu.Item
icon={<RiShutDownLine />}
destructive={true}
onClick={() =>
logout({ logoutParams: { returnTo: window.location.origin } })
}
onClick={() => signOut()}
>
Log out
</Menu.Item>

View file

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

View file

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

View file

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

View file

@ -8,14 +8,14 @@ import { useUserApi } from '@maybe-finance/client/shared'
import type { StepProps } from '../StepProps'
export function EmailVerification({ title, onNext }: StepProps) {
const { useAuth0Profile, useResendEmailVerification } = useUserApi()
const { useAuthProfile, useResendEmailVerification } = useUserApi()
const emailVerified = useRef(false)
const profile = useAuth0Profile({
const profile = useAuthProfile({
refetchInterval: emailVerified.current ? false : 5_000,
onSuccess: (data) => {
if (data.email_verified) {
if (data.emailVerified) {
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%)',
}}
>
{profile.data?.email_verified ? (
{profile.data?.emailVerified ? (
<RiMailCheckLine className="w-6 h-6" />
) : (
<RiMailSendLine className="w-6 h-6" />
@ -78,10 +78,10 @@ export function EmailVerification({ title, onNext }: StepProps) {
</div>
</div>
<h3 className="mt-12 text-center">
{profile.data?.email_verified ? 'Email verified' : title}
{profile.data?.emailVerified ? 'Email verified' : title}
</h3>
<div className="text-base text-center">
{profile.data?.email_verified ? (
{profile.data?.emailVerified ? (
<p className="mt-4 text-gray-50">
You have successfully verified{' '}
<span className="text-gray-25">{profile.data?.email ?? 'your email'}</span>
@ -130,7 +130,7 @@ export function EmailVerification({ title, onNext }: StepProps) {
</>
)}
</div>
{profile.data?.email_verified && (
{profile.data?.emailVerified && (
<Button className="mt-5" fullWidth onClick={onNext}>
Continue setup <RiArrowRightLine className="ml-2 w-5 h-5" />
</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 { LoadingSpinner } from '@maybe-finance/design-system'
import type { SharedType } from '@maybe-finance/shared'
@ -22,7 +22,6 @@ function shouldRedirect(path: string, data?: SharedType.UserSubscription) {
export function SubscriberGuard({ children }: PropsWithChildren) {
const router = useRouter()
const { logout } = useAuth0()
const { useSubscription } = useUserApi()
const subscription = useSubscription()
@ -31,7 +30,7 @@ export function SubscriberGuard({ children }: PropsWithChildren) {
<MainContentOverlay
title="Unable to load subscription"
actionText="Log out"
onAction={() => logout({ logoutParams: { returnTo: window.location.origin } })}
onAction={() => signOut()}
>
<p>Contact us if this issue persists.</p>
</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 { Controller, useForm } from 'react-hook-form'
import { useAuth0 } from '@auth0/auth0-react'
import { signOut } from 'next-auth/react'
import classNames from 'classnames'
import { AiOutlineLoading3Quarters as LoadingIcon } from 'react-icons/ai'
import {
RiAnticlockwise2Line,
RiAppleFill,
RiArrowGoBackFill,
RiDownloadLine,
RiShareForwardLine,
} from 'react-icons/ri'
import { UserIdentityList } from '../user-details/UserIdentityList'
import {
Button,
DatePicker,
@ -30,10 +28,8 @@ import { DeleteUserButton } from './DeleteUserButton'
import { DateTime } from 'luxon'
export function UserDetails() {
const { logout } = useAuth0()
const { useProfile, useAuth0Profile, useUpdateProfile } = useUserApi()
const { useProfile, useUpdateProfile } = useUserApi()
const auth0ProfileQuery = useAuth0Profile()
const updateProfileQuery = useUpdateProfile()
const profileQuery = useProfile()
@ -77,17 +73,7 @@ export function UserDetails() {
type="text"
/>
</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>
{auth0ProfileQuery.data && (
<UserIdentityList profile={auth0ProfileQuery.data} />
)}
</LoadingPlaceholder>
</section>
@ -101,9 +87,7 @@ export function UserDetails() {
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.
</p>
<DeleteUserButton
onDelete={() => logout({ logoutParams: { returnTo: window.location.origin } })}
/>
<DeleteUserButton onDelete={() => signOut()} />
</section>
</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 './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 { Button, LoadingSpinner } from '@maybe-finance/design-system'
import { MultiFactorAuthentication } from './MultiFactorAuthentication'
import { PasswordReset } from './PasswordReset'
export function SecurityPreferences() {
const { useAuth0Profile } = useUserApi()
const profileQuery = useAuth0Profile()
if (profileQuery.isLoading) {
return <LoadingSpinner />
}
if (profileQuery.isError) {
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>
<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 './useAccountConnectionApi'
export * from './useAuthUserApi'
export * from './useFinicityApi'
export * from './useInstitutionApi'
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 { SharedType } from '@maybe-finance/shared'
import type { Auth0ContextInterface } from '@auth0/auth0-react'
import type { AxiosInstance } from 'axios'
import Axios from 'axios'
import * as Sentry from '@sentry/react'
import { useMemo } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'react-hot-toast'
import { DateTime } from 'luxon'
import { useAxiosWithAuth } from '../hooks/useAxiosWithAuth'
import { useAuth0 } from '@auth0/auth0-react'
const UserApi = (
axios: AxiosInstance,
auth0: Auth0ContextInterface<SharedType.Auth0ReactUser>
) => ({
const UserApi = (axios: AxiosInstance) => ({
async getNetWorthSeries(start: string, end: string) {
const { data } = await axios.get<SharedType.NetWorthTimeSeriesResponse>(
`/users/net-worth`,
@ -65,16 +59,8 @@ const UserApi = (
return data
},
async getAuth0Profile() {
const { data } = await axios.get<SharedType.Auth0Profile>('/users/auth0-profile')
return data
},
async updateAuth0Profile(newProfile: SharedType.UpdateAuth0User) {
const { data } = await axios.put<
SharedType.Auth0User,
SharedType.ApiResponse<SharedType.Auth0User>
>('/users/auth0-profile', newProfile)
async getAuthProfile() {
const { data } = await axios.get<SharedType.AuthUser>('/users/auth-profile')
return data
},
@ -83,49 +69,6 @@ const UserApi = (
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) {
const { data } = await axios.put<
SharedType.PasswordReset,
@ -134,36 +77,10 @@ const UserApi = (
return data
},
async linkAccounts({ secondaryJWT, secondaryProvider }: SharedType.LinkAccounts) {
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) {
async resendEmailVerification(authId?: string) {
const { data } = await axios.post<{ success: boolean }>(
'/users/resend-verification-email',
{ auth0Id }
{ authId }
)
return data
@ -201,8 +118,7 @@ const staleTimes = {
export function useUserApi() {
const queryClient = useQueryClient()
const { axios } = useAxiosWithAuth()
const auth0 = useAuth0()
const api = useMemo(() => UserApi(axios, auth0), [axios, auth0])
const api = useMemo(() => UserApi(axios), [axios])
const useNetWorthSeries = (
{ start, end }: { start: string; end: string },
@ -288,21 +204,14 @@ export function useUserApi() {
...options,
})
const useAuth0Profile = (
options?: Omit<UseQueryOptions<SharedType.Auth0Profile>, 'queryKey' | 'queryFn'>
) => useQuery(['users', 'auth0-profile'], api.getAuth0Profile, options)
const useUpdateAuth0Profile = (
options?: UseMutationOptions<
SharedType.Auth0User | undefined,
unknown,
SharedType.UpdateAuth0User
const useAuthProfile = (
options?: Omit<
UseQueryOptions<SharedType.AuthUser, unknown, SharedType.AuthUser, any[]>,
'queryKey' | 'queryFn'
>
) =>
useMutation(api.updateAuth0Profile, {
onSettled() {
queryClient.invalidateQueries(['users', 'auth0-profile'])
},
useQuery(['auth-profile'], api.getAuthProfile, {
staleTime: staleTimes.user,
...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 = (
options?: UseMutationOptions<{ success: boolean } | undefined, unknown, string | undefined>
) =>
@ -403,12 +285,9 @@ export function useUserApi() {
useCurrentNetWorth,
useProfile,
useUpdateProfile,
useAuth0Profile,
useUpdateAuth0Profile,
useAuthProfile,
useSubscription,
useChangePassword,
useLinkAccounts,
useUnlinkAccount,
useResendEmailVerification,
useCreateCheckoutSession,
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 { useState } from 'react'
import axios from 'axios'
@ -12,7 +12,7 @@ export interface FeedbackDialogProps {
export function FeedbackDialog({ isOpen, onClose, notImplementedNotice }: FeedbackDialogProps) {
const [feedback, setFeedback] = useState('')
const { user } = useAuth0()
const { data: session } = useSession()
return (
<Dialog isOpen={isOpen} onClose={onClose}>
@ -41,10 +41,14 @@ export function FeedbackDialog({ isOpen, onClose, notImplementedNotice }: Feedba
try {
await axios
.create({ transformRequest: [(data) => JSON.stringify(data)] })
.post('https://hooks.zapier.com/hooks/catch/10143005/buyo6na/', {
comment: `**From user:** ${user?.sub}\n\n${feedback}`,
.post(
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}`,
})
}
)
toast.success('Your feedback was submitted!')
} catch (e) {

View file

@ -1,3 +1,2 @@
export * from './FeedbackDialog'
export * from './ConfirmDialog'
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 { SharedType } from '@maybe-finance/shared'
import { superjson } from '@maybe-finance/shared'
import { createContext, type PropsWithChildren, useCallback, useMemo } from 'react'
import { useAuth0 } from '@auth0/auth0-react'
import { createContext, type PropsWithChildren, useMemo } from 'react'
import Axios from 'axios'
import * as Sentry from '@sentry/react'
import { useRouter } from 'next/router'
type CreateInstanceOptions = {
getToken?: () => Promise<string | null>
@ -15,7 +12,6 @@ type CreateInstanceOptions = {
}
export type AxiosContextValue = {
getToken: () => Promise<string | null>
defaultBaseUrl: string
axios: AxiosInstance
createInstance: (options?: CreateInstanceOptions) => AxiosInstance
@ -71,63 +67,29 @@ function createInstance(options?: CreateInstanceOptions) {
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) {
// 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 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
const defaultInstance = useMemo(() => {
const defaultHeaders = { 'Content-Type': 'application/json' }
const defaultHeaders = {
'Content-Type': 'application/json',
'Access-Control-Allow-Credentials': true,
}
return createInstance({
getToken,
axiosOptions: { baseURL: `${API_URL}/v1`, headers: defaultHeaders },
axiosOptions: {
baseURL: `${API_URL}/v1`,
headers: defaultHeaders,
withCredentials: true,
},
serialize: true,
deserialize: true,
})
}, [getToken, API_URL])
}, [API_URL])
return (
<AxiosContext.Provider
value={{
getToken,
defaultBaseUrl: `${API_URL}/v1`,
axios: defaultInstance,
createInstance,

View file

@ -5,4 +5,3 @@ export * from './QueryProvider'
export * from './AccountContextProvider'
export * from './UserAccountContextProvider'
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'
function isJSON(str: string): boolean {
try {
JSON.parse(str)
return true
} catch (e) {
return false
}
}
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.height ??= width

View file

@ -2,4 +2,3 @@ export * from './image-loaders'
export * from './browser-utils'
export * from './account-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 { IconType } from 'react-icons'
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 classNames from 'classnames'
import { RiArrowDownSFill, RiCheckFill } from 'react-icons/ri'
@ -166,6 +166,8 @@ function Options({
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null)
const [isOpen, setIsOpen] = useState(false)
const isOpenRef = useRef(false)
const { styles, attributes, update } = usePopper(referenceElement, popperElement, {
placement,
modifiers: [
@ -180,6 +182,7 @@ function Options({
useEffect(() => {
if (isOpen && update) update()
if (isOpenRef.current !== isOpen) setIsOpen(isOpenRef.current)
}, [isOpen, update])
return (
@ -198,7 +201,7 @@ function Options({
{...rest}
>
{({ open }) => {
setIsOpen(open)
isOpenRef.current = open
return children
}}
</HeadlessListbox.Options>

View file

@ -46,6 +46,8 @@ function Items({
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null)
const [isOpen, setIsOpen] = useState(false)
const isOpenRef = useRef(false)
const { styles, attributes, update } = usePopper(referenceElement?.current, popperElement, {
placement,
modifiers: [
@ -60,6 +62,7 @@ function Items({
useEffect(() => {
if (isOpen && update) update()
if (isOpenRef.current !== isOpen) setIsOpen(isOpenRef.current)
}, [isOpen, update])
return (
@ -75,7 +78,7 @@ function Items({
{...rest}
>
{(renderProps) => {
setIsOpen(renderProps.open)
isOpenRef.current = renderProps.open
return typeof children === 'function' ? children(renderProps) : children
}}
</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 { SendEmailQueueJobData } from '@maybe-finance/server/shared'
import type { IEmailService } from './email.service'
import type { ManagementClient } from 'auth0'
import { DateTime } from 'luxon'
export interface IEmailProcessor {
@ -14,7 +13,6 @@ export class EmailProcessor implements IEmailProcessor {
constructor(
private readonly logger: Logger,
private readonly prisma: PrismaClient,
private readonly auth0: ManagementClient,
private readonly emailService: IEmailService
) {}

View file

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

View file

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

View file

@ -1,16 +1,6 @@
import type {
AccountCategory,
AccountType,
PrismaClient,
User,
} from '@prisma/client'
import type { AccountCategory, AccountType, PrismaClient, User } from '@prisma/client'
import type { Logger } from 'winston'
import {
AuthUtil,
type PurgeUserQueue,
type SyncUserQueue,
} from '@maybe-finance/server/shared'
import type { ManagementClient, UnlinkAccountsParamsProvider } from 'auth0'
import type { PurgeUserQueue, SyncUserQueue } from '@maybe-finance/server/shared'
import type Stripe from 'stripe'
import type { IBalanceSyncStrategyFactory } from '../account-balance'
import type { IAccountQueryService } from '../account'
@ -64,7 +54,6 @@ export class UserService implements IUserService {
private readonly balanceSyncStrategyFactory: IBalanceSyncStrategyFactory,
private readonly syncQueue: SyncUserQueue,
private readonly purgeQueue: PurgeUserQueue,
private readonly auth0: ManagementClient,
private readonly stripe: Stripe
) {}
@ -74,46 +63,11 @@ export class UserService implements IUserService {
})
}
async getAuth0Profile(user: User): Promise<SharedType.Auth0Profile> {
if (!user.email) throw new Error('No email found for user')
const usersWithMatchingEmail = await this.auth0.getUsersByEmail(user.email)
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 getAuthProfile(id: User['id']): Promise<SharedType.AuthUser> {
const user = await this.get(id)
return this.prisma.authUser.findUniqueOrThrow({
where: { id: user.authId },
})
}
async sync(id: User['id']) {
@ -166,9 +120,10 @@ export class UserService implements IUserService {
// Delete Stripe customer, ending any active subscriptions
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
this.logger.info(`Removing user ${user.id} from Auth0 (${user.auth0Id})`)
await this.auth0.deleteUser({ id: user.auth0Id })
// Delete user from Auth so that it cannot be accessed in a partially-purged state
// TODO: Update this to use new Auth
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 })
@ -321,7 +276,7 @@ export class UserService implements IUserService {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
select: {
auth0Id: true,
authId: true,
onboarding: true,
dob: 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
const email_verified = auth0User.email_verified as unknown as string | boolean
// NextAuth used DateTime for this field
const email_verified = authUser.emailVerified === null ? false : true
const typedOnboarding = user.onboarding as OnboardingState | null
const onboardingState = typedOnboarding
@ -350,8 +307,8 @@ export class UserService implements IUserService {
{
...user,
onboarding: onboardingState,
emailVerified: email_verified === true || email_verified === 'true',
isAppleIdentity: auth0User.identities?.[0].provider === 'apple',
emailVerified: email_verified,
isAppleIdentity: false,
},
onboardingState.markedComplete
)
@ -375,7 +332,7 @@ export class UserService implements IUserService {
.setTitle((_) => "Before we start, let's verify your email")
.addToGroup('setup')
.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
.addStep('firstAccount')
@ -551,45 +508,4 @@ export class UserService implements IUserService {
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,
audience: string
): Promise<{
auth0Id: string
authId: string
userMetadata: SharedType.MaybeUserMetadata
appMetadata: SharedType.MaybeAppMetadata
}> {
@ -50,7 +50,7 @@ export async function validateRS256JWT(
if (typeof payload !== 'object') return reject('payload not an object')
resolve({
auth0Id: payload.sub!,
authId: payload.sub!,
appMetadata: payload[SharedType.Auth0CustomNamespace.AppMetadata] ?? {},
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 {
AccountCategory,
AccountClassification,
@ -7,6 +5,7 @@ import type {
Prisma,
Security,
User as PrismaUser,
AuthUser,
} from '@prisma/client'
import type { Institution } from 'plaid'
import type { TimeSeries, TimeSeriesResponseWithDetail, Trend } from './general-types'
@ -27,6 +26,14 @@ export type UpdateUser = Partial<
}
>
/**
* ================================================================
* ====== Auth User ======
* ================================================================
*/
export type { AuthUser }
/**
* ================================================================
* ====== Net Worth ======
@ -182,39 +189,11 @@ export type MaybeCustomClaims = {
[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 {
currentPassword: 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 = {
subscribed: 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:circular": "npx madge --circular --extensions ts libs",
"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",
"prepare": "husky install"
},
@ -38,8 +34,7 @@
},
"private": true,
"dependencies": {
"@auth0/auth0-react": "^2.0.0",
"@auth0/nextjs-auth0": "^2.0.0",
"@auth/prisma-adapter": "^1.0.14",
"@bull-board/express": "^4.6.4",
"@casl/ability": "^6.3.2",
"@casl/prisma": "^1.4.1",
@ -93,8 +88,10 @@
"auth0-deploy-cli": "^7.15.1",
"autoprefixer": "10.4.13",
"axios": "^0.26.1",
"bcrypt": "^5.1.1",
"bull": "^4.10.2",
"classnames": "^2.3.1",
"cookie-parser": "^1.4.6",
"core-js": "^3.6.5",
"cors": "^2.8.5",
"crypto-js": "^4.1.1",
@ -122,6 +119,7 @@
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"next": "13.1.1",
"next-auth": "^4.24.5",
"pg": "^8.8.0",
"plaid": "^12.1.0",
"postcss": "8.4.19",
@ -195,6 +193,7 @@
"@testing-library/react": "13.4.0",
"@testing-library/user-event": "^13.2.1",
"@types/auth0": "^2.35.7",
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.12",
"@types/crypto-js": "^4.1.1",
"@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())
createdAt DateTime @default(now()) @map("created_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
email String @db.Citext
@ -560,6 +560,61 @@ model PlanMilestone {
@@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 {
pending
approved

238
yarn.lock
View file

@ -9,32 +9,25 @@
dependencies:
"@jridgewell/trace-mapping" "^0.3.0"
"@auth0/auth0-react@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@auth0/auth0-react/-/auth0-react-2.0.0.tgz#74e4d3662896e71dd95cca70b395715825da3b4e"
integrity sha512-3pf41wU6ksm/6uPYAwjX5bZ7ma/K4LethibagTrKkMPuS8UatBvxLDtl3Aq52ZlJi1I+I42ckEfzWqloNxssIg==
"@auth/core@0.20.0":
version "0.20.0"
resolved "https://registry.yarnpkg.com/@auth/core/-/core-0.20.0.tgz#18b706b9708973b1fd4cb6aac5ef47d89201f925"
integrity sha512-04lQH58H5d/9xQ63MOTDTOC7sXWYlr/RhJ97wfFLXzll7nYyCKbkrT3ZMdzdLC5O+qt90sQDK85TAtLlcZ2WBg==
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":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@auth0/auth0-spa-js/-/auth0-spa-js-2.0.2.tgz#fe0d5eeb6f0da48c24913a07b3565d48792de6d5"
integrity sha512-sxK9Lb6gXGImqjmWBfndA/OSNY4YLPTPwJEVuitXIOZ1p3EoqHM4zjIHvcdiYIaVo+cUfEf3l0bf8UA7Xi4tjg==
"@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==
"@auth/prisma-adapter@^1.0.14":
version "1.0.14"
resolved "https://registry.yarnpkg.com/@auth/prisma-adapter/-/prisma-adapter-1.0.14.tgz#3b46f86beec618ab5d6756fdd1b520535cf010ac"
integrity sha512-7urwnDT+K81SocU0SbfY/vtY/NbXgj8/AU2k6Ek8waHT/7YPLsOQnXQsTWROmolFshNVkt2kq9Z/HOVnRdHrkQ==
dependencies:
"@panva/hkdf" "^1.0.2"
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"
"@auth/core" "0.20.0"
"@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"
@ -1464,6 +1457,13 @@
dependencies:
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":
version "7.18.6"
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"
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":
version "1.6.22"
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"
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":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.0.4.tgz#f300fef4cc38008ff4b8c29d92588eced3ce014b"
@ -4407,6 +4427,13 @@
dependencies:
"@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@*":
version "1.19.2"
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
@ -4447,6 +4474,11 @@
dependencies:
"@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":
version "2.8.12"
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"
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:
version "1.3.7"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
@ -6841,6 +6878,14 @@ bcrypt-pbkdf@^1.0.0:
dependencies:
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:
version "2.4.3"
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:
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:
version "1.0.6"
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
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:
version "0.4.2"
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"
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
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.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
cookiejar@^2.1.2:
version "2.1.3"
@ -8894,6 +8952,11 @@ detab@2.0.4:
dependencies:
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:
version "3.1.0"
resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651"
@ -13074,16 +13137,21 @@ jose@^2.0.6:
dependencies:
"@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:
version "4.11.0"
resolved "https://registry.yarnpkg.com/jose/-/jose-4.11.0.tgz#1c7f5c7806383d3e836434e8f49da531cb046a9d"
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:
version "1.0.1"
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:
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:
version "2.1.2"
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"
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:
version "1.1.0"
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"
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:
version "0.1.17"
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"
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:
version "2.5.0"
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-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:
version "4.1.1"
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"
kind-of "^3.0.3"
object-hash@^2.0.1:
object-hash@^2.0.1, object-hash@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5"
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"
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:
version "2.4.1"
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"
oidc-token-hash "^5.0.1"
openid-client@^5.2.1:
version "5.3.1"
resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.3.1.tgz#69a5fa7d2b5ad479032f576852d40b4d4435488a"
integrity sha512-RLfehQiHch9N6tRWNx68cicf3b1WR0x74bJWHRc25uYIbSRwjxYcTFaRnzbbpls5jroLAaB/bFIodTgA5LJMvw==
openid-client@^5.4.0:
version "5.6.4"
resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.6.4.tgz#b2c25e6d5338ba3ce00e04341bb286798a196177"
integrity sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA==
dependencies:
jose "^4.10.0"
jose "^4.15.4"
lru-cache "^6.0.0"
object-hash "^2.0.1"
oidc-token-hash "^5.0.1"
object-hash "^2.2.0"
oidc-token-hash "^5.0.3"
opn@latest:
version "6.0.0"
@ -16143,11 +16258,30 @@ postmark@^3.0.14:
dependencies:
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"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.11.3.tgz#8a7e4ba19d3992c488b0785afcc0f8aa13c78d19"
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:
version "1.2.1"
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"
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:
version "1.0.3"
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"
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:
version "0.15.0"
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"
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:
version "6.0.8"
resolved "https://registry.yarnpkg.com/telejson/-/telejson-6.0.8.tgz#1c432db7e7a9212c1fbd941c3e5174ec385148f7"