mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +02:00
commit
47056203d0
82 changed files with 1078 additions and 1640 deletions
13
.env.example
13
.env.example
|
@ -8,14 +8,17 @@ NX_POLYGON_API_KEY=
|
||||||
# If using free ngrok account for webhooks
|
# If using free ngrok account for webhooks
|
||||||
NGROK_AUTH_TOKEN=
|
NGROK_AUTH_TOKEN=
|
||||||
|
|
||||||
# Required for Auth0 deploy client (see `yarn auth0:deploy` command)
|
|
||||||
AUTH0_ENV=development
|
|
||||||
NX_AUTH0_MGMT_CLIENT_SECRET=
|
|
||||||
NX_AUTH0_CLIENT_SECRET=
|
|
||||||
AUTH0_DEPLOY_CLIENT_SECRET=
|
|
||||||
POSTMARK_SMTP_PASS=
|
POSTMARK_SMTP_PASS=
|
||||||
NX_SESSION_SECRET=
|
NX_SESSION_SECRET=
|
||||||
|
|
||||||
|
# Generate a new secret using openssl rand -base64 32
|
||||||
|
NEXTAUTH_SECRET=
|
||||||
|
NEXTAUTH_URL=http://localhost:4200
|
||||||
|
NX_NEXTAUTH_URL=http://localhost:4200
|
||||||
|
|
||||||
NX_PLAID_SECRET=
|
NX_PLAID_SECRET=
|
||||||
NX_FINICITY_APP_KEY=
|
NX_FINICITY_APP_KEY=
|
||||||
NX_FINICITY_PARTNER_SECRET=
|
NX_FINICITY_PARTNER_SECRET=
|
||||||
|
NX_CONVERTKIT_SECRET=
|
||||||
|
|
||||||
|
NEXT_PUBLIC_ZAPIER_FEEDBACK_HOOK_URL=
|
||||||
|
|
|
@ -43,8 +43,17 @@ This is the current state of building the app. You'll hit errors, which we're wo
|
||||||
|
|
||||||
You'll need Docker installed to run the app locally.
|
You'll need Docker installed to run the app locally.
|
||||||
|
|
||||||
|
First, copy the `.env.example` file to `.env`:
|
||||||
|
|
||||||
```
|
```
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, create a new secret using `openssl rand -base64 32` and populate `NEXTAUTH_SECRET` in your `.env` file with it.
|
||||||
|
|
||||||
|
Then run the following yarn commands:
|
||||||
|
|
||||||
|
```
|
||||||
yarn install
|
yarn install
|
||||||
yarn run dev:services
|
yarn run dev:services
|
||||||
yarn prisma:migrate:dev
|
yarn prisma:migrate:dev
|
||||||
|
|
|
@ -1,19 +1,19 @@
|
||||||
import { useAuth0 } from '@auth0/auth0-react'
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import * as Sentry from '@sentry/react'
|
import * as Sentry from '@sentry/react'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
|
|
||||||
export default function APM() {
|
export default function APM() {
|
||||||
const { user } = useAuth0()
|
const { data: session } = useSession()
|
||||||
|
|
||||||
// Identify Sentry user
|
// Identify Sentry user
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (session && session.user) {
|
||||||
Sentry.setUser({
|
Sentry.setUser({
|
||||||
id: user.sub,
|
id: session.user['sub'] ?? undefined,
|
||||||
email: user.email,
|
email: session.user['https://maybe.co'] ?? undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [user])
|
}, [session])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,13 +1,10 @@
|
||||||
const env = {
|
const env = {
|
||||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3333',
|
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3333',
|
||||||
NEXT_PUBLIC_AUTH0_DOMAIN:
|
NEXT_PUBLIC_AUTH0_DOMAIN: process.env.NEXT_PUBLIC_AUTH0_DOMAIN || 'REPLACE_THIS',
|
||||||
process.env.NEXT_PUBLIC_AUTH0_DOMAIN || 'REPLACE_THIS',
|
NEXT_PUBLIC_AUTH0_CLIENT_ID: process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID || 'REPLACE_THIS',
|
||||||
NEXT_PUBLIC_AUTH0_CLIENT_ID:
|
|
||||||
process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID || 'REPLACE_THIS',
|
|
||||||
NEXT_PUBLIC_AUTH0_AUDIENCE:
|
NEXT_PUBLIC_AUTH0_AUDIENCE:
|
||||||
process.env.NEXT_PUBLIC_AUTH0_AUDIENCE || 'https://maybe-finance-api/v1',
|
process.env.NEXT_PUBLIC_AUTH0_AUDIENCE || 'https://maybe-finance-api/v1',
|
||||||
NEXT_PUBLIC_LD_CLIENT_SIDE_ID:
|
NEXT_PUBLIC_LD_CLIENT_SIDE_ID: process.env.NEXT_PUBLIC_LD_CLIENT_SIDE_ID || 'REPLACE_THIS',
|
||||||
process.env.NEXT_PUBLIC_LD_CLIENT_SIDE_ID || 'REPLACE_THIS',
|
|
||||||
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
NEXT_PUBLIC_SENTRY_ENV: process.env.NEXT_PUBLIC_SENTRY_ENV,
|
NEXT_PUBLIC_SENTRY_ENV: process.env.NEXT_PUBLIC_SENTRY_ENV,
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { PropsWithChildren, ReactElement } from 'react'
|
import { useEffect, type PropsWithChildren, type ReactElement } from 'react'
|
||||||
import type { AppProps } from 'next/app'
|
import type { AppProps } from 'next/app'
|
||||||
import { ErrorBoundary } from 'react-error-boundary'
|
import { ErrorBoundary } from 'react-error-boundary'
|
||||||
import { Analytics } from '@vercel/analytics/react'
|
import { Analytics } from '@vercel/analytics/react'
|
||||||
|
@ -8,18 +8,17 @@ import {
|
||||||
ErrorFallback,
|
ErrorFallback,
|
||||||
LogProvider,
|
LogProvider,
|
||||||
UserAccountContextProvider,
|
UserAccountContextProvider,
|
||||||
AuthProvider,
|
|
||||||
} from '@maybe-finance/client/shared'
|
} from '@maybe-finance/client/shared'
|
||||||
import { AccountsManager } from '@maybe-finance/client/features'
|
import { AccountsManager, OnboardingGuard } from '@maybe-finance/client/features'
|
||||||
import { AccountContextProvider } from '@maybe-finance/client/shared'
|
import { AccountContextProvider } from '@maybe-finance/client/shared'
|
||||||
import * as Sentry from '@sentry/react'
|
import * as Sentry from '@sentry/react'
|
||||||
import { BrowserTracing } from '@sentry/tracing'
|
import { BrowserTracing } from '@sentry/tracing'
|
||||||
import env from '../env'
|
import env from '../env'
|
||||||
import '../styles.css'
|
import '../styles.css'
|
||||||
import { withAuthenticationRequired } from '@auth0/auth0-react'
|
import { SessionProvider, useSession } from 'next-auth/react'
|
||||||
import ModalManager from '../components/ModalManager'
|
|
||||||
import Meta from '../components/Meta'
|
import Meta from '../components/Meta'
|
||||||
import APM from '../components/APM'
|
import APM from '../components/APM'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
|
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
@ -33,20 +32,30 @@ Sentry.init({
|
||||||
})
|
})
|
||||||
|
|
||||||
// Providers and components only relevant to a logged-in user
|
// Providers and components only relevant to a logged-in user
|
||||||
const WithAuth = withAuthenticationRequired(function ({ children }: PropsWithChildren) {
|
const WithAuth = function ({ children }: PropsWithChildren) {
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session) {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}, [session, router])
|
||||||
|
|
||||||
|
if (session) {
|
||||||
return (
|
return (
|
||||||
<ModalManager>
|
<OnboardingGuard>
|
||||||
<UserAccountContextProvider>
|
<UserAccountContextProvider>
|
||||||
<AccountContextProvider>
|
<AccountContextProvider>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{/* Add, edit, delete connections and manual accounts */}
|
|
||||||
<AccountsManager />
|
<AccountsManager />
|
||||||
</AccountContextProvider>
|
</AccountContextProvider>
|
||||||
</UserAccountContextProvider>
|
</UserAccountContextProvider>
|
||||||
</ModalManager>
|
</OnboardingGuard>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export default function App({
|
export default function App({
|
||||||
Component: Page,
|
Component: Page,
|
||||||
|
@ -71,7 +80,7 @@ export default function App({
|
||||||
<Meta />
|
<Meta />
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<AuthProvider>
|
<SessionProvider>
|
||||||
<AxiosProvider>
|
<AxiosProvider>
|
||||||
<>
|
<>
|
||||||
<APM />
|
<APM />
|
||||||
|
@ -82,7 +91,7 @@ export default function App({
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
</AxiosProvider>
|
</AxiosProvider>
|
||||||
</AuthProvider>
|
</SessionProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</LogProvider>
|
</LogProvider>
|
||||||
|
|
145
apps/client/pages/api/auth/[...nextauth].ts
Normal file
145
apps/client/pages/api/auth/[...nextauth].ts
Normal 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)
|
|
@ -1,20 +1,116 @@
|
||||||
import { useAuth0 } from '@auth0/auth0-react'
|
import { useState, type ReactElement } from 'react'
|
||||||
import { LoadingSpinner } from '@maybe-finance/design-system'
|
import { FullPageLayout } from '@maybe-finance/client/features'
|
||||||
|
import { Input, InputPassword, Button } from '@maybe-finance/design-system'
|
||||||
|
import { signIn, useSession } from 'next-auth/react'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import Script from 'next/script'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { isAuthenticated } = useAuth0()
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [isValid, setIsValid] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data: session } = useSession()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) router.push('/')
|
if (session) {
|
||||||
}, [isAuthenticated, router])
|
router.push('/')
|
||||||
|
}
|
||||||
|
}, [session, router])
|
||||||
|
|
||||||
|
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setErrorMessage(null)
|
||||||
|
setPassword('')
|
||||||
|
|
||||||
|
const response = await signIn('credentials', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
redirect: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response && response.error) {
|
||||||
|
setErrorMessage(response.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// _app.tsx will automatically redirect if not authenticated
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<>
|
||||||
<LoadingSpinner />
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/zxcvbn/4.4.2/zxcvbn.js"
|
||||||
|
strategy="lazyOnload"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<div className="p-px w-80 md:w-96 bg-white bg-opacity-10 card-light rounded-3xl radial-gradient-background">
|
||||||
|
<div className="bg-black bg-opacity-75 p-8 rounded-3xl w-full h-full items-center flex flex-col radial-gradient-background-dark">
|
||||||
|
<img
|
||||||
|
className="mb-8"
|
||||||
|
src="/assets/maybe-box.svg"
|
||||||
|
alt="Maybe Finance Logo"
|
||||||
|
height={120}
|
||||||
|
width={120}
|
||||||
|
/>
|
||||||
|
<form className="space-y-4 w-full px-4" onSubmit={onSubmit}>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputPassword
|
||||||
|
autoComplete="password"
|
||||||
|
label="Password"
|
||||||
|
value={password}
|
||||||
|
showPasswordRequirements={!isValid}
|
||||||
|
onValidityChange={(checks) => {
|
||||||
|
const passwordValid =
|
||||||
|
checks.filter((c) => !c.isValid).length === 0
|
||||||
|
setIsValid(passwordValid)
|
||||||
|
}}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setPassword(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errorMessage && password.length === 0 ? (
|
||||||
|
<div className="py-1 text-center text-red text-sm">
|
||||||
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid}
|
||||||
|
variant={isValid ? 'primary' : 'secondary'}
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</Button>
|
||||||
|
<div className="text-sm text-gray-50 pt-2">
|
||||||
|
<div>
|
||||||
|
Don'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
|
||||||
|
|
|
@ -1,20 +1,134 @@
|
||||||
import { useAuth0 } from '@auth0/auth0-react'
|
import { useState, type ReactElement } from 'react'
|
||||||
import { LoadingSpinner } from '@maybe-finance/design-system'
|
import { Input, InputPassword, Button } from '@maybe-finance/design-system'
|
||||||
|
import { FullPageLayout } from '@maybe-finance/client/features'
|
||||||
|
import { signIn, useSession } from 'next-auth/react'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import Script from 'next/script'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const { isAuthenticated } = useAuth0()
|
const [firstName, setFirstName] = useState('')
|
||||||
|
const [lastName, setLastName] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [isValid, setIsValid] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data: session } = useSession()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) router.push('/')
|
if (session) router.push('/')
|
||||||
}, [isAuthenticated, router])
|
}, [session, router])
|
||||||
|
|
||||||
|
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
setErrorMessage(null)
|
||||||
|
setFirstName('')
|
||||||
|
setLastName('')
|
||||||
|
setEmail('')
|
||||||
|
setPassword('')
|
||||||
|
|
||||||
|
const response = await signIn('credentials', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
redirect: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response && response.error) {
|
||||||
|
setErrorMessage(response.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// _app.tsx will automatically redirect if not authenticated
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<>
|
||||||
<LoadingSpinner />
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/zxcvbn/4.4.2/zxcvbn.js"
|
||||||
|
strategy="lazyOnload"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<div className="p-px w-96 bg-white bg-opacity-10 card-light rounded-3xl radial-gradient-background">
|
||||||
|
<div className="bg-black bg-opacity-75 p-8 rounded-3xl w-full h-full items-center flex flex-col radial-gradient-background-dark">
|
||||||
|
<img
|
||||||
|
className="mb-8"
|
||||||
|
src="/assets/maybe-box.svg"
|
||||||
|
alt="Maybe Finance Logo"
|
||||||
|
height={120}
|
||||||
|
width={120}
|
||||||
|
/>
|
||||||
|
<form className="space-y-4 w-full px-4" onSubmit={onSubmit}>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="First name"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Last name"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputPassword
|
||||||
|
autoComplete="password"
|
||||||
|
label="Password"
|
||||||
|
value={password}
|
||||||
|
showPasswordRequirements={!isValid}
|
||||||
|
onValidityChange={(checks) => {
|
||||||
|
const passwordValid =
|
||||||
|
checks.filter((c) => !c.isValid).length === 0
|
||||||
|
setIsValid(passwordValid)
|
||||||
|
}}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setPassword(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errorMessage && password.length === 0 ? (
|
||||||
|
<div className="py-1 text-center text-red text-sm">
|
||||||
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid}
|
||||||
|
variant={isValid ? 'primary' : 'secondary'}
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</Button>
|
||||||
|
<div className="text-sm text-gray-50 pt-2">
|
||||||
|
<div>
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link
|
||||||
|
className="hover:text-cyan-400 underline font-medium"
|
||||||
|
href="/login"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RegisterPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <FullPageLayout>{page}</FullPageLayout>
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterPage.isPublic = true
|
||||||
|
|
|
@ -3,7 +3,6 @@ import { useQueryParam } from '@maybe-finance/client/shared'
|
||||||
import {
|
import {
|
||||||
AccountSidebar,
|
AccountSidebar,
|
||||||
BillingPreferences,
|
BillingPreferences,
|
||||||
GeneralPreferences,
|
|
||||||
SecurityPreferences,
|
SecurityPreferences,
|
||||||
UserDetails,
|
UserDetails,
|
||||||
WithSidebarLayout,
|
WithSidebarLayout,
|
||||||
|
@ -35,7 +34,6 @@ export default function SettingsPage() {
|
||||||
>
|
>
|
||||||
<Tab.List>
|
<Tab.List>
|
||||||
<Tab>Details</Tab>
|
<Tab>Details</Tab>
|
||||||
<Tab>Notifications</Tab>
|
|
||||||
<Tab>Security</Tab>
|
<Tab>Security</Tab>
|
||||||
<Tab>Billing</Tab>
|
<Tab>Billing</Tab>
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
|
@ -43,11 +41,6 @@ export default function SettingsPage() {
|
||||||
<Tab.Panel>
|
<Tab.Panel>
|
||||||
<UserDetails />
|
<UserDetails />
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel>
|
|
||||||
<div className="mt-6 max-w-lg text-base">
|
|
||||||
<GeneralPreferences />
|
|
||||||
</div>
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel>
|
<Tab.Panel>
|
||||||
<div className="mt-6 max-w-lg">
|
<div className="mt-6 max-w-lg">
|
||||||
<SecurityPreferences />
|
<SecurityPreferences />
|
||||||
|
|
|
@ -145,3 +145,20 @@
|
||||||
height: 0;
|
height: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.radial-gradient-background {
|
||||||
|
background-image: radial-gradient(
|
||||||
|
60% 200% at 50% 50%,
|
||||||
|
rgba(67, 97, 238, 0.5) 0%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radial-gradient-background-dark {
|
||||||
|
background-image: radial-gradient(
|
||||||
|
100% 100% at clamp(20%, calc(30% + var(--mx) * 0.05), 40%)
|
||||||
|
clamp(50%, calc(50% + var(--my) * 0.05), 60%),
|
||||||
|
#4361ee33 0%,
|
||||||
|
#16161af4 120%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@ import { createLogger, transports } from 'winston'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import { PgService } from '@maybe-finance/server/shared'
|
import { PgService } from '@maybe-finance/server/shared'
|
||||||
import { AccountQueryService, UserService } from '@maybe-finance/server/features'
|
import { AccountQueryService, UserService } from '@maybe-finance/server/features'
|
||||||
import { managementClient } from '../lib/auth0'
|
|
||||||
import { resetUser } from './utils/user'
|
import { resetUser } from './utils/user'
|
||||||
jest.mock('plaid')
|
jest.mock('plaid')
|
||||||
jest.mock('auth0')
|
jest.mock('auth0')
|
||||||
|
@ -38,7 +37,6 @@ describe('user net worth', () => {
|
||||||
},
|
},
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
managementClient,
|
|
||||||
{} as any
|
{} as any
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ import { PrismaClient } from '@prisma/client'
|
||||||
import { createLogger, transports } from 'winston'
|
import { createLogger, transports } from 'winston'
|
||||||
import { AccountQueryService, UserService } from '@maybe-finance/server/features'
|
import { AccountQueryService, UserService } from '@maybe-finance/server/features'
|
||||||
import { resetUser } from './utils/user'
|
import { resetUser } from './utils/user'
|
||||||
import { managementClient } from '../lib/auth0'
|
|
||||||
import stripe from '../lib/stripe'
|
import stripe from '../lib/stripe'
|
||||||
import { PgService } from '@maybe-finance/server/shared'
|
import { PgService } from '@maybe-finance/server/shared'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
|
@ -18,7 +17,6 @@ const userService = new UserService(
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
managementClient,
|
|
||||||
stripe
|
stripe
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -23,10 +23,7 @@ export async function getAxiosClient() {
|
||||||
password: 'REPLACE_THIS',
|
password: 'REPLACE_THIS',
|
||||||
audience: 'https://maybe-finance-api/v1',
|
audience: 'https://maybe-finance-api/v1',
|
||||||
scope: '',
|
scope: '',
|
||||||
client_id: isCI
|
client_id: isCI ? 'REPLACE_THIS' : 'REPLACE_THIS',
|
||||||
? 'REPLACE_THIS'
|
|
||||||
: 'REPLACE_THIS',
|
|
||||||
client_secret: env.NX_AUTH0_CLIENT_SECRET,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,7 @@ import logger from './lib/logger'
|
||||||
import prisma from './lib/prisma'
|
import prisma from './lib/prisma'
|
||||||
import {
|
import {
|
||||||
defaultErrorHandler,
|
defaultErrorHandler,
|
||||||
validateAuth0Jwt,
|
validateAuthJwt,
|
||||||
superjson,
|
superjson,
|
||||||
authErrorHandler,
|
authErrorHandler,
|
||||||
maintenance,
|
maintenance,
|
||||||
|
@ -30,7 +30,6 @@ import {
|
||||||
usersRouter,
|
usersRouter,
|
||||||
accountsRouter,
|
accountsRouter,
|
||||||
connectionsRouter,
|
connectionsRouter,
|
||||||
adminRouter,
|
|
||||||
webhooksRouter,
|
webhooksRouter,
|
||||||
plaidRouter,
|
plaidRouter,
|
||||||
accountRollupRouter,
|
accountRollupRouter,
|
||||||
|
@ -88,11 +87,10 @@ app.use(express.static(__dirname + '/assets'))
|
||||||
|
|
||||||
const origin = [env.NX_CLIENT_URL, ...env.NX_CORS_ORIGINS]
|
const origin = [env.NX_CLIENT_URL, ...env.NX_CORS_ORIGINS]
|
||||||
logger.info(`CORS origins: ${origin}`)
|
logger.info(`CORS origins: ${origin}`)
|
||||||
app.use(cors({ origin }))
|
app.use(cors({ origin, credentials: true }))
|
||||||
app.options('*', cors() as RequestHandler)
|
app.options('*', cors() as RequestHandler)
|
||||||
|
|
||||||
app.set('view engine', 'ejs').set('views', __dirname + '/app/admin/views')
|
app.set('view engine', 'ejs').set('views', __dirname + '/app/admin/views')
|
||||||
app.use('/admin', adminRouter)
|
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
morgan(env.NX_MORGAN_LOG_LEVEL, {
|
morgan(env.NX_MORGAN_LOG_LEVEL, {
|
||||||
|
@ -116,7 +114,7 @@ app.use(express.json({ limit: '50mb' })) // Finicity sends large response bodies
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
'/trpc',
|
'/trpc',
|
||||||
validateAuth0Jwt,
|
validateAuthJwt,
|
||||||
trpcExpress.createExpressMiddleware({
|
trpcExpress.createExpressMiddleware({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: createTRPCContext,
|
createContext: createTRPCContext,
|
||||||
|
@ -150,7 +148,7 @@ app.use('/v1', webhooksRouter)
|
||||||
app.use('/v1', publicRouter)
|
app.use('/v1', publicRouter)
|
||||||
|
|
||||||
// All routes AFTER this line are protected via OAuth
|
// All routes AFTER this line are protected via OAuth
|
||||||
app.use('/v1', validateAuth0Jwt)
|
app.use('/v1', validateAuthJwt)
|
||||||
|
|
||||||
// Private routes
|
// Private routes
|
||||||
app.use('/v1/users', usersRouter)
|
app.use('/v1/users', usersRouter)
|
||||||
|
|
|
@ -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',
|
|
||||||
})
|
|
|
@ -24,6 +24,7 @@ import Redis from 'ioredis'
|
||||||
import {
|
import {
|
||||||
AccountService,
|
AccountService,
|
||||||
AccountConnectionService,
|
AccountConnectionService,
|
||||||
|
AuthUserService,
|
||||||
UserService,
|
UserService,
|
||||||
EmailService,
|
EmailService,
|
||||||
AccountQueryService,
|
AccountQueryService,
|
||||||
|
@ -56,7 +57,6 @@ import plaid, { getPlaidWebhookUrl } from './plaid'
|
||||||
import finicity, { getFinicityTxPushUrl, getFinicityWebhookUrl } from './finicity'
|
import finicity, { getFinicityTxPushUrl, getFinicityWebhookUrl } from './finicity'
|
||||||
import stripe from './stripe'
|
import stripe from './stripe'
|
||||||
import postmark from './postmark'
|
import postmark from './postmark'
|
||||||
import { managementClient } from './auth0'
|
|
||||||
import defineAbilityFor from './ability'
|
import defineAbilityFor from './ability'
|
||||||
import env from '../../env'
|
import env from '../../env'
|
||||||
import logger from '../lib/logger'
|
import logger from '../lib/logger'
|
||||||
|
@ -205,6 +205,10 @@ const accountService = new AccountService(
|
||||||
balanceSyncStrategyFactory
|
balanceSyncStrategyFactory
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// auth-user
|
||||||
|
|
||||||
|
const authUserService = new AuthUserService(logger.child({ service: 'AuthUserService' }), prisma)
|
||||||
|
|
||||||
// user
|
// user
|
||||||
|
|
||||||
const userService = new UserService(
|
const userService = new UserService(
|
||||||
|
@ -214,7 +218,6 @@ const userService = new UserService(
|
||||||
balanceSyncStrategyFactory,
|
balanceSyncStrategyFactory,
|
||||||
queueService.getQueue('sync-user'),
|
queueService.getQueue('sync-user'),
|
||||||
queueService.getQueue('purge-user'),
|
queueService.getQueue('purge-user'),
|
||||||
managementClient,
|
|
||||||
stripe
|
stripe
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -276,22 +279,23 @@ const stripeWebhooks = new StripeWebhookHandler(
|
||||||
)
|
)
|
||||||
|
|
||||||
// helper function for parsing JWT and loading User record
|
// helper function for parsing JWT and loading User record
|
||||||
|
// TODO: update this with roles, identity, and metadata
|
||||||
async function getCurrentUser(jwt: NonNullable<Request['user']>) {
|
async function getCurrentUser(jwt: NonNullable<Request['user']>) {
|
||||||
if (!jwt.sub) throw new Error(`jwt missing sub`)
|
if (!jwt.sub) throw new Error(`jwt missing sub`)
|
||||||
if (!jwt['https://maybe.co/email']) throw new Error(`jwt missing email`)
|
if (!jwt['https://maybe.co/email']) throw new Error(`jwt missing email`)
|
||||||
|
|
||||||
const user =
|
const user =
|
||||||
(await prisma.user.findUnique({
|
(await prisma.user.findUnique({
|
||||||
where: { auth0Id: jwt.sub },
|
where: { authId: jwt.sub },
|
||||||
})) ??
|
})) ??
|
||||||
(await prisma.user.upsert({
|
(await prisma.user.upsert({
|
||||||
where: { auth0Id: jwt.sub },
|
where: { authId: jwt.sub },
|
||||||
create: {
|
create: {
|
||||||
auth0Id: jwt.sub,
|
authId: jwt.sub,
|
||||||
email: jwt['https://maybe.co/email'],
|
email: jwt['https://maybe.co/email'],
|
||||||
picture: jwt[SharedType.Auth0CustomNamespace.Picture],
|
picture: jwt['picture'],
|
||||||
firstName: jwt[SharedType.Auth0CustomNamespace.UserMetadata]?.['firstName'],
|
firstName: jwt['firstName'],
|
||||||
lastName: jwt[SharedType.Auth0CustomNamespace.UserMetadata]?.['lastName'],
|
lastName: jwt['lastName'],
|
||||||
},
|
},
|
||||||
update: {},
|
update: {},
|
||||||
}))
|
}))
|
||||||
|
@ -312,7 +316,6 @@ export async function createContext(req: Request) {
|
||||||
prisma,
|
prisma,
|
||||||
plaid,
|
plaid,
|
||||||
stripe,
|
stripe,
|
||||||
managementClient,
|
|
||||||
logger,
|
logger,
|
||||||
user,
|
user,
|
||||||
ability: defineAbilityFor(user),
|
ability: defineAbilityFor(user),
|
||||||
|
@ -320,6 +323,7 @@ export async function createContext(req: Request) {
|
||||||
transactionService,
|
transactionService,
|
||||||
holdingService,
|
holdingService,
|
||||||
accountConnectionService,
|
accountConnectionService,
|
||||||
|
authUserService,
|
||||||
userService,
|
userService,
|
||||||
valuationService,
|
valuationService,
|
||||||
institutionService,
|
institutionService,
|
||||||
|
|
|
@ -3,7 +3,7 @@ import * as Sentry from '@sentry/node'
|
||||||
|
|
||||||
export const identifySentryUser: ErrorRequestHandler = (err, req, _res, next) => {
|
export const identifySentryUser: ErrorRequestHandler = (err, req, _res, next) => {
|
||||||
Sentry.setUser({
|
Sentry.setUser({
|
||||||
auth0Id: req.user?.sub,
|
authId: req.user?.sub,
|
||||||
})
|
})
|
||||||
|
|
||||||
next(err)
|
next(err)
|
||||||
|
|
|
@ -2,7 +2,7 @@ export * from './dev-only'
|
||||||
export * from './error-handler'
|
export * from './error-handler'
|
||||||
export * from './auth-error-handler'
|
export * from './auth-error-handler'
|
||||||
export * from './superjson'
|
export * from './superjson'
|
||||||
export * from './validate-auth0-jwt'
|
export * from './validate-auth-jwt'
|
||||||
export * from './validate-plaid-jwt'
|
export * from './validate-plaid-jwt'
|
||||||
export * from './validate-finicity-signature'
|
export * from './validate-finicity-signature'
|
||||||
export { default as maintenance } from './maintenance'
|
export { default as maintenance } from './maintenance'
|
||||||
|
|
33
apps/server/src/app/middleware/validate-auth-jwt.ts
Normal file
33
apps/server/src/app/middleware/validate-auth-jwt.ts
Normal 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' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -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'],
|
|
||||||
})
|
|
|
@ -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
|
|
|
@ -47,13 +47,13 @@ router.post(
|
||||||
trialLapsed: z.boolean().default(false),
|
trialLapsed: z.boolean().default(false),
|
||||||
}),
|
}),
|
||||||
resolve: async ({ ctx, input }) => {
|
resolve: async ({ ctx, input }) => {
|
||||||
ctx.logger.debug(`Resetting CI user ${ctx.user!.auth0Id}`)
|
ctx.logger.debug(`Resetting CI user ${ctx.user!.authId}`)
|
||||||
|
|
||||||
await ctx.prisma.$transaction([
|
await ctx.prisma.$transaction([
|
||||||
ctx.prisma.$executeRaw`DELETE FROM "user" WHERE auth0_id=${ctx.user!.auth0Id};`,
|
ctx.prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${ctx.user!.authId};`,
|
||||||
ctx.prisma.user.create({
|
ctx.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
auth0Id: ctx.user!.auth0Id,
|
authId: ctx.user!.authId,
|
||||||
email: 'REPLACE_THIS',
|
email: 'REPLACE_THIS',
|
||||||
dob: new Date('1990-01-01'),
|
dob: new Date('1990-01-01'),
|
||||||
linkAccountDismissedAt: new Date(), // ensures our auto-account link doesn't trigger
|
linkAccountDismissedAt: new Date(), // ensures our auto-account link doesn't trigger
|
||||||
|
|
|
@ -5,7 +5,6 @@ export { default as usersRouter } from './users.router'
|
||||||
export { default as webhooksRouter } from './webhooks.router'
|
export { default as webhooksRouter } from './webhooks.router'
|
||||||
export { default as plaidRouter } from './plaid.router'
|
export { default as plaidRouter } from './plaid.router'
|
||||||
export { default as finicityRouter } from './finicity.router'
|
export { default as finicityRouter } from './finicity.router'
|
||||||
export { default as adminRouter } from './admin.router'
|
|
||||||
export { default as valuationsRouter } from './valuations.router'
|
export { default as valuationsRouter } from './valuations.router'
|
||||||
export { default as institutionsRouter } from './institutions.router'
|
export { default as institutionsRouter } from './institutions.router'
|
||||||
export { default as transactionsRouter } from './transactions.router'
|
export { default as transactionsRouter } from './transactions.router'
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import axios from 'axios'
|
|
||||||
import type { UnlinkAccountsParamsProvider } from 'auth0'
|
|
||||||
import { subject } from '@casl/ability'
|
import { subject } from '@casl/ability'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { DateUtil, type SharedType } from '@maybe-finance/shared'
|
import { DateUtil, type SharedType } from '@maybe-finance/shared'
|
||||||
|
@ -106,25 +104,10 @@ router.put(
|
||||||
)
|
)
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/auth0-profile',
|
'/auth-profile',
|
||||||
endpoint.create({
|
endpoint.create({
|
||||||
resolve: async ({ ctx }) => {
|
resolve: async ({ ctx }) => {
|
||||||
return ctx.userService.getAuth0Profile(ctx.user!)
|
return ctx.userService.getAuthProfile(ctx.user!.id)
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
router.put(
|
|
||||||
'/auth0-profile',
|
|
||||||
endpoint.create({
|
|
||||||
input: z.object({
|
|
||||||
enrolled_mfa: z.boolean(),
|
|
||||||
}),
|
|
||||||
resolve: ({ input, ctx }) => {
|
|
||||||
return ctx.managementClient.updateUser(
|
|
||||||
{ id: ctx.user!.auth0Id },
|
|
||||||
{ user_metadata: { enrolled_mfa: input.enrolled_mfa } }
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
@ -276,53 +259,20 @@ router.get(
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
router.post(
|
// TODO: Implement verification email using Postmark instead of Auth0
|
||||||
'/link-accounts',
|
|
||||||
endpoint.create({
|
|
||||||
input: z.object({
|
|
||||||
secondaryJWT: z.string(),
|
|
||||||
secondaryProvider: z.string(),
|
|
||||||
}),
|
|
||||||
resolve: async ({ input, ctx }) => {
|
|
||||||
return ctx.userService.linkAccounts(ctx.user!.auth0Id, input.secondaryProvider, {
|
|
||||||
token: input.secondaryJWT,
|
|
||||||
domain: env.NX_AUTH0_CUSTOM_DOMAIN,
|
|
||||||
audience: env.NX_AUTH0_AUDIENCE,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/unlink-account',
|
|
||||||
endpoint.create({
|
|
||||||
input: z.object({
|
|
||||||
secondaryAuth0Id: z.string(),
|
|
||||||
secondaryProvider: z.string(),
|
|
||||||
}),
|
|
||||||
resolve: async ({ input, ctx }) => {
|
|
||||||
return ctx.userService.unlinkAccounts(
|
|
||||||
ctx.user!.auth0Id,
|
|
||||||
input.secondaryAuth0Id,
|
|
||||||
input.secondaryProvider as UnlinkAccountsParamsProvider
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/resend-verification-email',
|
'/resend-verification-email',
|
||||||
endpoint.create({
|
endpoint.create({
|
||||||
input: z.object({
|
input: z.object({
|
||||||
auth0Id: z.string().optional(),
|
authId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
resolve: async ({ input, ctx }) => {
|
resolve: async ({ input, ctx }) => {
|
||||||
const auth0Id = input.auth0Id ?? ctx.user?.auth0Id
|
const authId = input.authId ?? ctx.user?.authId
|
||||||
if (!auth0Id) throw new Error('User not found')
|
if (!authId) throw new Error('User not found')
|
||||||
|
|
||||||
await ctx.managementClient.sendEmailVerification({ user_id: auth0Id })
|
//await ctx.managementClient.sendEmailVerification({ user_id: authId })
|
||||||
|
|
||||||
ctx.logger.info(`Sent verification email to ${auth0Id}`)
|
ctx.logger.info(`Sent verification email to ${authId}`)
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
},
|
},
|
||||||
|
@ -341,52 +291,18 @@ router.put(
|
||||||
throw new Error('Unable to update password. No user found.')
|
throw new Error('Unable to update password. No user found.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await ctx.managementClient.getUser({ id: req.user.sub })
|
|
||||||
|
|
||||||
const { newPassword, currentPassword } = input
|
const { newPassword, currentPassword } = input
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth0 doesn't have a verify password endpoint on the Management API, so this is a secure way to
|
|
||||||
* verify that the old password was valid before changing it. Why they don't have this feature still? ¯\_(ツ)_/¯
|
|
||||||
*
|
|
||||||
* @see https://community.auth0.com/t/change-password-validation/8158/10
|
|
||||||
*/
|
|
||||||
try {
|
try {
|
||||||
// If this succeeds, we know the old password was correct
|
await ctx.authUserService.updatePassword(req.user.sub, currentPassword, newPassword)
|
||||||
await axios.post(
|
|
||||||
`https://${env.NX_AUTH0_DOMAIN}/oauth/token`,
|
|
||||||
{
|
|
||||||
grant_type: 'password',
|
|
||||||
username: user.email,
|
|
||||||
password: currentPassword,
|
|
||||||
audience: env.NX_AUTH0_AUDIENCE,
|
|
||||||
client_id: env.NX_AUTH0_CLIENT_ID,
|
|
||||||
client_secret: env.NX_AUTH0_CLIENT_SECRET,
|
|
||||||
},
|
|
||||||
{ headers: { 'content-type': 'application/json' } }
|
|
||||||
)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let errMessage = 'Could not reset password'
|
const errMessage = 'Could not reset password'
|
||||||
|
|
||||||
if (axios.isAxiosError(err)) {
|
|
||||||
errMessage =
|
|
||||||
err.response?.status === 401
|
|
||||||
? 'Invalid password, please try again'
|
|
||||||
: errMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do not log the full error here, the user's password could be in it!
|
// Do not log the full error here, the user's password could be in it!
|
||||||
ctx.logger.error('Could not reset password')
|
ctx.logger.error('Could not reset password')
|
||||||
|
|
||||||
return { success: false, error: errMessage }
|
return { success: false, error: errMessage }
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://auth0.com/docs/connections/database/password-change#use-the-management-api
|
|
||||||
await ctx.managementClient.updateUser(
|
|
||||||
{ id: req.user?.sub },
|
|
||||||
{ password: newPassword, connection: 'Username-Password-Authentication' }
|
|
||||||
)
|
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -426,9 +342,8 @@ router.post(
|
||||||
customer: ctx.user.stripeCustomerId,
|
customer: ctx.user.stripeCustomerId,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
customer_email: (
|
customer_email:
|
||||||
await ctx.managementClient.getUser({ id: req.user.sub })
|
(await ctx.authUserService.get(req.user.sub)).email ?? undefined,
|
||||||
).email,
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -33,15 +33,6 @@ const envSchema = z.object({
|
||||||
|
|
||||||
NX_NGROK_URL: z.string().default('http://localhost:4551'),
|
NX_NGROK_URL: z.string().default('http://localhost:4551'),
|
||||||
|
|
||||||
// Dev doesn't have a custom domain, so replace with the original dev URL
|
|
||||||
NX_AUTH0_DOMAIN: z.string().default('REPLACE_THIS'),
|
|
||||||
NX_AUTH0_CUSTOM_DOMAIN: z.string().default('REPLACE_THIS'),
|
|
||||||
NX_AUTH0_AUDIENCE: z.string().default('https://maybe-finance-api/v1'),
|
|
||||||
NX_AUTH0_CLIENT_ID: z.string().default('REPLACE_THIS'),
|
|
||||||
NX_AUTH0_CLIENT_SECRET: z.string(),
|
|
||||||
NX_AUTH0_MGMT_CLIENT_ID: z.string().default('REPLACE_THIS'),
|
|
||||||
NX_AUTH0_MGMT_CLIENT_SECRET: z.string(),
|
|
||||||
|
|
||||||
NX_PLAID_CLIENT_ID: z.string().default('REPLACE_THIS'),
|
NX_PLAID_CLIENT_ID: z.string().default('REPLACE_THIS'),
|
||||||
NX_PLAID_SECRET: z.string(),
|
NX_PLAID_SECRET: z.string(),
|
||||||
NX_PLAID_ENV: z.string().default('sandbox'),
|
NX_PLAID_ENV: z.string().default('sandbox'),
|
||||||
|
|
|
@ -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',
|
|
||||||
})
|
|
|
@ -28,14 +28,12 @@ import {
|
||||||
LoanBalanceSyncStrategy,
|
LoanBalanceSyncStrategy,
|
||||||
PlaidETL,
|
PlaidETL,
|
||||||
PlaidService,
|
PlaidService,
|
||||||
PropertyService,
|
|
||||||
SecurityPricingProcessor,
|
SecurityPricingProcessor,
|
||||||
SecurityPricingService,
|
SecurityPricingService,
|
||||||
TransactionBalanceSyncStrategy,
|
TransactionBalanceSyncStrategy,
|
||||||
UserProcessor,
|
UserProcessor,
|
||||||
UserService,
|
UserService,
|
||||||
ValuationBalanceSyncStrategy,
|
ValuationBalanceSyncStrategy,
|
||||||
VehicleService,
|
|
||||||
EmailService,
|
EmailService,
|
||||||
EmailProcessor,
|
EmailProcessor,
|
||||||
TransactionService,
|
TransactionService,
|
||||||
|
@ -58,7 +56,6 @@ import prisma from './prisma'
|
||||||
import plaid from './plaid'
|
import plaid from './plaid'
|
||||||
import finicity from './finicity'
|
import finicity from './finicity'
|
||||||
import postmark from './postmark'
|
import postmark from './postmark'
|
||||||
import { managementClient } from './auth0'
|
|
||||||
import stripe from './stripe'
|
import stripe from './stripe'
|
||||||
import env from '../../env'
|
import env from '../../env'
|
||||||
import { BullQueueEventHandler, WorkerErrorHandlerService } from '../services'
|
import { BullQueueEventHandler, WorkerErrorHandlerService } from '../services'
|
||||||
|
@ -127,10 +124,6 @@ const finicityService = new FinicityService(
|
||||||
env.NX_FINICITY_ENV === 'sandbox'
|
env.NX_FINICITY_ENV === 'sandbox'
|
||||||
)
|
)
|
||||||
|
|
||||||
const propertyService = new PropertyService(logger.child({ service: 'PropertyService' }))
|
|
||||||
|
|
||||||
const vehicleService = new VehicleService(logger.child({ service: 'VehicleService' }))
|
|
||||||
|
|
||||||
// account-connection
|
// account-connection
|
||||||
|
|
||||||
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
|
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
|
||||||
|
@ -228,7 +221,6 @@ export const userService: IUserService = new UserService(
|
||||||
balanceSyncStrategyFactory,
|
balanceSyncStrategyFactory,
|
||||||
queueService.getQueue('sync-user'),
|
queueService.getQueue('sync-user'),
|
||||||
queueService.getQueue('purge-user'),
|
queueService.getQueue('purge-user'),
|
||||||
managementClient,
|
|
||||||
stripe
|
stripe
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -282,6 +274,5 @@ export const emailService: IEmailService = new EmailService(
|
||||||
export const emailProcessor: IEmailProcessor = new EmailProcessor(
|
export const emailProcessor: IEmailProcessor = new EmailProcessor(
|
||||||
logger.child({ service: 'EmailProcessor' }),
|
logger.child({ service: 'EmailProcessor' }),
|
||||||
prisma,
|
prisma,
|
||||||
managementClient,
|
|
||||||
emailService
|
emailService
|
||||||
)
|
)
|
||||||
|
|
|
@ -82,7 +82,7 @@ export class BullQueueEventHandler implements IBullQueueEventHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getUserFromJob(job: Job) {
|
private async getUserFromJob(job: Job) {
|
||||||
let user: Pick<User, 'id' | 'auth0Id'> | undefined
|
let user: Pick<User, 'id' | 'authId'> | undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (job.queue.name === 'sync-account' && 'accountId' in job.data) {
|
if (job.queue.name === 'sync-account' && 'accountId' in job.data) {
|
||||||
|
|
|
@ -24,25 +24,13 @@ const envSchema = z.object({
|
||||||
|
|
||||||
NX_POLYGON_API_KEY: z.string().default(''),
|
NX_POLYGON_API_KEY: z.string().default(''),
|
||||||
|
|
||||||
NX_AUTH0_DOMAIN: z.string().default('REPLACE_THIS'),
|
|
||||||
NX_AUTH0_MGMT_CLIENT_ID: z.string().default('REPLACE_THIS'),
|
|
||||||
NX_AUTH0_MGMT_CLIENT_SECRET: z.string(),
|
|
||||||
|
|
||||||
NX_POSTMARK_FROM_ADDRESS: z.string().default('account@maybe.co'),
|
NX_POSTMARK_FROM_ADDRESS: z.string().default('account@maybe.co'),
|
||||||
NX_POSTMARK_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'),
|
NX_POSTMARK_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'),
|
||||||
NX_POSTMARK_API_TOKEN: z.string().default('REPLACE_THIS'),
|
NX_POSTMARK_API_TOKEN: z.string().default('REPLACE_THIS'),
|
||||||
NX_STRIPE_SECRET_KEY: z
|
NX_STRIPE_SECRET_KEY: z.string().default('sk_test_REPLACE_THIS'),
|
||||||
.string()
|
|
||||||
.default(
|
|
||||||
'sk_test_REPLACE_THIS'
|
|
||||||
),
|
|
||||||
|
|
||||||
NX_CDN_PRIVATE_BUCKET: z
|
NX_CDN_PRIVATE_BUCKET: z.string().default('REPLACE_THIS'),
|
||||||
.string()
|
NX_CDN_PUBLIC_BUCKET: z.string().default('REPLACE_THIS'),
|
||||||
.default('REPLACE_THIS'),
|
|
||||||
NX_CDN_PUBLIC_BUCKET: z
|
|
||||||
.string()
|
|
||||||
.default('REPLACE_THIS'),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const env = envSchema.parse(process.env)
|
const env = envSchema.parse(process.env)
|
||||||
|
|
|
@ -1,21 +1,16 @@
|
||||||
import type { SharedType } from '@maybe-finance/shared'
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
import { BrowserUtil, useAccountApi, useAccountContext } from '@maybe-finance/client/shared'
|
import { useAccountContext } from '@maybe-finance/client/shared'
|
||||||
import { Menu } from '@maybe-finance/design-system'
|
import { Menu } from '@maybe-finance/design-system'
|
||||||
import { RiDeleteBin5Line, RiPencilLine, RiRefreshLine } from 'react-icons/ri'
|
import { RiDeleteBin5Line, RiPencilLine } from 'react-icons/ri'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useAuth0 } from '@auth0/auth0-react'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
account?: SharedType.AccountDetail
|
account?: SharedType.AccountDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountMenu({ account }: Props) {
|
export function AccountMenu({ account }: Props) {
|
||||||
const { user } = useAuth0()
|
|
||||||
const { editAccount, deleteAccount } = useAccountContext()
|
const { editAccount, deleteAccount } = useAccountContext()
|
||||||
const { useSyncAccount } = useAccountApi()
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const syncAccount = useSyncAccount()
|
|
||||||
|
|
||||||
if (!account) return null
|
if (!account) return null
|
||||||
|
|
||||||
|
@ -28,15 +23,6 @@ export function AccountMenu({ account }: Props) {
|
||||||
<Menu.Item icon={<RiPencilLine />} onClick={() => editAccount(account)}>
|
<Menu.Item icon={<RiPencilLine />} onClick={() => editAccount(account)}>
|
||||||
Edit
|
Edit
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{BrowserUtil.hasRole(user, 'Admin') && (
|
|
||||||
<Menu.Item
|
|
||||||
icon={<RiRefreshLine />}
|
|
||||||
destructive
|
|
||||||
onClick={() => syncAccount.mutate(account.id)}
|
|
||||||
>
|
|
||||||
Sync
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{!account.accountConnectionId && (
|
{!account.accountConnectionId && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
icon={<RiDeleteBin5Line />}
|
icon={<RiDeleteBin5Line />}
|
||||||
|
|
|
@ -4,13 +4,11 @@ export * from './holdings-list'
|
||||||
export * from './insights'
|
export * from './insights'
|
||||||
export * from './user-billing'
|
export * from './user-billing'
|
||||||
export * from './user-details'
|
export * from './user-details'
|
||||||
export * from './user-notifications'
|
|
||||||
export * from './user-security'
|
export * from './user-security'
|
||||||
export * from './transactions-list'
|
export * from './transactions-list'
|
||||||
export * from './investment-transactions-list'
|
export * from './investment-transactions-list'
|
||||||
export * from './layout'
|
export * from './layout'
|
||||||
export * from './accounts-manager'
|
export * from './accounts-manager'
|
||||||
export * from './user'
|
|
||||||
export * from './net-worth-insights'
|
export * from './net-worth-insights'
|
||||||
export * from './data-editor'
|
export * from './data-editor'
|
||||||
export * from './loan-details'
|
export * from './loan-details'
|
||||||
|
|
|
@ -19,14 +19,12 @@ import {
|
||||||
RiMore2Fill,
|
RiMore2Fill,
|
||||||
RiPieChart2Line,
|
RiPieChart2Line,
|
||||||
RiFlagLine,
|
RiFlagLine,
|
||||||
RiChatPollLine,
|
|
||||||
RiArrowRightSLine,
|
RiArrowRightSLine,
|
||||||
} from 'react-icons/ri'
|
} from 'react-icons/ri'
|
||||||
import { Button, Tooltip } from '@maybe-finance/design-system'
|
import { Button, Tooltip } from '@maybe-finance/design-system'
|
||||||
import { useAuth0 } from '@auth0/auth0-react'
|
|
||||||
import { MenuPopover } from './MenuPopover'
|
import { MenuPopover } from './MenuPopover'
|
||||||
import { UpgradePrompt } from '../user-billing'
|
|
||||||
import { SidebarOnboarding } from '../onboarding'
|
import { SidebarOnboarding } from '../onboarding'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
|
|
||||||
export interface DesktopLayoutProps {
|
export interface DesktopLayoutProps {
|
||||||
sidebar: React.ReactNode
|
sidebar: React.ReactNode
|
||||||
|
@ -95,7 +93,8 @@ export function DesktopLayout({ children, sidebar }: DesktopLayoutProps) {
|
||||||
const [onboardingExpanded, setOnboardingExpanded] = useState(false)
|
const [onboardingExpanded, setOnboardingExpanded] = useState(false)
|
||||||
|
|
||||||
const { popoutContents, close: closePopout } = usePopoutContext()
|
const { popoutContents, close: closePopout } = usePopoutContext()
|
||||||
const { user } = useAuth0()
|
const { data: session } = useSession()
|
||||||
|
const user = session!.user
|
||||||
const { useOnboarding, useUpdateOnboarding } = useUserApi()
|
const { useOnboarding, useUpdateOnboarding } = useUserApi()
|
||||||
const onboarding = useOnboarding('sidebar')
|
const onboarding = useOnboarding('sidebar')
|
||||||
const updateOnboarding = useUpdateOnboarding()
|
const updateOnboarding = useUpdateOnboarding()
|
||||||
|
@ -270,8 +269,8 @@ export function DesktopLayout({ children, sidebar }: DesktopLayoutProps) {
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
}
|
}
|
||||||
name={user?.name}
|
name={user?.name ?? ''}
|
||||||
email={user?.email}
|
email={user?.email ?? ''}
|
||||||
>
|
>
|
||||||
{sidebar}
|
{sidebar}
|
||||||
</DefaultContent>
|
</DefaultContent>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useAuth0 } from '@auth0/auth0-react'
|
import { signOut } from 'next-auth/react'
|
||||||
import { Menu } from '@maybe-finance/design-system'
|
import { Menu } from '@maybe-finance/design-system'
|
||||||
import type { ComponentProps } from 'react'
|
import type { ComponentProps } from 'react'
|
||||||
import {
|
import {
|
||||||
|
@ -16,8 +16,6 @@ export function MenuPopover({
|
||||||
placement?: ComponentProps<typeof Menu.Item>['placement']
|
placement?: ComponentProps<typeof Menu.Item>['placement']
|
||||||
isHeader: boolean
|
isHeader: boolean
|
||||||
}) {
|
}) {
|
||||||
const { logout } = useAuth0()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu>
|
<Menu>
|
||||||
<Menu.Button variant="icon">{icon}</Menu.Button>
|
<Menu.Button variant="icon">{icon}</Menu.Button>
|
||||||
|
@ -31,11 +29,7 @@ export function MenuPopover({
|
||||||
<Menu.ItemNextLink icon={<RiDatabase2Line />} href="/data-editor">
|
<Menu.ItemNextLink icon={<RiDatabase2Line />} href="/data-editor">
|
||||||
Fix my data
|
Fix my data
|
||||||
</Menu.ItemNextLink>
|
</Menu.ItemNextLink>
|
||||||
<Menu.Item
|
<Menu.Item icon={<LogoutIcon />} destructive={true} onClick={() => signOut()}>
|
||||||
icon={<LogoutIcon />}
|
|
||||||
destructive={true}
|
|
||||||
onClick={() => logout({ logoutParams: { returnTo: window.location.origin } })}
|
|
||||||
>
|
|
||||||
Log out
|
Log out
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Items>
|
</Menu.Items>
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import {
|
import {
|
||||||
RiChatPollLine,
|
|
||||||
RiCloseLine,
|
RiCloseLine,
|
||||||
RiFlagLine,
|
RiFlagLine,
|
||||||
RiFolderOpenLine,
|
RiFolderOpenLine,
|
||||||
|
@ -15,7 +14,6 @@ import Link from 'next/link'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { ProfileCircle } from '@maybe-finance/client/shared'
|
import { ProfileCircle } from '@maybe-finance/client/shared'
|
||||||
import { usePopoutContext, LayoutContextProvider } from '@maybe-finance/client/shared'
|
import { usePopoutContext, LayoutContextProvider } from '@maybe-finance/client/shared'
|
||||||
import { UpgradePrompt } from '../user-billing'
|
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import type { IconType } from 'react-icons'
|
import type { IconType } from 'react-icons'
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { MainContentOverlay, useUserApi } from '@maybe-finance/client/shared'
|
||||||
import { LoadingSpinner } from '@maybe-finance/design-system'
|
import { LoadingSpinner } from '@maybe-finance/design-system'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import type { SharedType } from '@maybe-finance/shared'
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
import { useAuth0 } from '@auth0/auth0-react'
|
import { signOut } from 'next-auth/react'
|
||||||
|
|
||||||
function shouldRedirect(pathname: string, data?: SharedType.OnboardingResponse) {
|
function shouldRedirect(pathname: string, data?: SharedType.OnboardingResponse) {
|
||||||
if (!data) return false
|
if (!data) return false
|
||||||
|
@ -14,7 +14,6 @@ function shouldRedirect(pathname: string, data?: SharedType.OnboardingResponse)
|
||||||
|
|
||||||
export function OnboardingGuard({ children }: PropsWithChildren) {
|
export function OnboardingGuard({ children }: PropsWithChildren) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { logout } = useAuth0()
|
|
||||||
const { useOnboarding } = useUserApi()
|
const { useOnboarding } = useUserApi()
|
||||||
const onboarding = useOnboarding('main', {
|
const onboarding = useOnboarding('main', {
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
@ -29,7 +28,7 @@ export function OnboardingGuard({ children }: PropsWithChildren) {
|
||||||
<MainContentOverlay
|
<MainContentOverlay
|
||||||
title="Unable to load onboarding"
|
title="Unable to load onboarding"
|
||||||
actionText="Logout"
|
actionText="Logout"
|
||||||
onAction={() => logout({ logoutParams: { returnTo: window.location.origin } })}
|
onAction={() => signOut()}
|
||||||
>
|
>
|
||||||
<p>Contact us if this issue persists.</p>
|
<p>Contact us if this issue persists.</p>
|
||||||
</MainContentOverlay>
|
</MainContentOverlay>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useAuth0 } from '@auth0/auth0-react'
|
import { signOut } from 'next-auth/react'
|
||||||
import { ProfileCircle } from '@maybe-finance/client/shared'
|
import { ProfileCircle } from '@maybe-finance/client/shared'
|
||||||
import { Button, Menu } from '@maybe-finance/design-system'
|
import { Button, Menu } from '@maybe-finance/design-system'
|
||||||
import type { SharedType } from '@maybe-finance/shared'
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
|
@ -15,8 +15,6 @@ type Props = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OnboardingNavbar({ steps, currentStep, onBack }: Props) {
|
export function OnboardingNavbar({ steps, currentStep, onBack }: Props) {
|
||||||
const { logout } = useAuth0()
|
|
||||||
|
|
||||||
const groups = uniqBy(steps, 'group')
|
const groups = uniqBy(steps, 'group')
|
||||||
.map((s) => s.group)
|
.map((s) => s.group)
|
||||||
.filter((g): g is string => g != null)
|
.filter((g): g is string => g != null)
|
||||||
|
@ -85,9 +83,7 @@ export function OnboardingNavbar({ steps, currentStep, onBack }: Props) {
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
icon={<RiShutDownLine />}
|
icon={<RiShutDownLine />}
|
||||||
destructive={true}
|
destructive={true}
|
||||||
onClick={() =>
|
onClick={() => signOut()}
|
||||||
logout({ logoutParams: { returnTo: window.location.origin } })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Log out
|
Log out
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
|
@ -237,7 +237,7 @@ export function SidebarOnboarding({ onClose, onHide }: Props) {
|
||||||
const description = getDescriptionComponent(step.key)
|
const description = getDescriptionComponent(step.key)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Disclosure>
|
<Disclosure key={idx}>
|
||||||
{({ open }) => (
|
{({ open }) => (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
|
|
@ -2,16 +2,15 @@ import Link from 'next/link'
|
||||||
import { useUserApi } from '@maybe-finance/client/shared'
|
import { useUserApi } from '@maybe-finance/client/shared'
|
||||||
import { Button } from '@maybe-finance/design-system'
|
import { Button } from '@maybe-finance/design-system'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { useAuth0 } from '@auth0/auth0-react'
|
import { signOut } from 'next-auth/react'
|
||||||
|
|
||||||
export function CountryWaitlist({ country }: { country?: string }) {
|
export function CountryWaitlist({ country }: { country?: string }) {
|
||||||
const { logout } = useAuth0()
|
|
||||||
const { useDelete } = useUserApi()
|
const { useDelete } = useUserApi()
|
||||||
|
|
||||||
const deleteUser = useDelete({
|
const deleteUser = useDelete({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
toast.success(`Account deleted`)
|
toast.success(`Account deleted`)
|
||||||
setTimeout(() => logout({ logoutParams: { returnTo: window.location.origin } }), 500)
|
setTimeout(() => signOut(), 500)
|
||||||
},
|
},
|
||||||
onError() {
|
onError() {
|
||||||
toast.error(`Error deleting account`)
|
toast.error(`Error deleting account`)
|
||||||
|
|
|
@ -59,6 +59,8 @@ export function AddFirstAccount({ title, onNext }: StepProps) {
|
||||||
loader={BrowserUtil.enhancerizerLoader}
|
loader={BrowserUtil.enhancerizerLoader}
|
||||||
src={`financial-institutions/white/${src}.svg`}
|
src={`financial-institutions/white/${src}.svg`}
|
||||||
alt={name}
|
alt={name}
|
||||||
|
height={96}
|
||||||
|
width={96}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -8,14 +8,14 @@ import { useUserApi } from '@maybe-finance/client/shared'
|
||||||
import type { StepProps } from '../StepProps'
|
import type { StepProps } from '../StepProps'
|
||||||
|
|
||||||
export function EmailVerification({ title, onNext }: StepProps) {
|
export function EmailVerification({ title, onNext }: StepProps) {
|
||||||
const { useAuth0Profile, useResendEmailVerification } = useUserApi()
|
const { useAuthProfile, useResendEmailVerification } = useUserApi()
|
||||||
|
|
||||||
const emailVerified = useRef(false)
|
const emailVerified = useRef(false)
|
||||||
|
|
||||||
const profile = useAuth0Profile({
|
const profile = useAuthProfile({
|
||||||
refetchInterval: emailVerified.current ? false : 5_000,
|
refetchInterval: emailVerified.current ? false : 5_000,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.email_verified) {
|
if (data.emailVerified) {
|
||||||
emailVerified.current = true
|
emailVerified.current = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -70,7 +70,7 @@ export function EmailVerification({ title, onNext }: StepProps) {
|
||||||
'linear-gradient(180deg, rgba(35, 36, 40, 0.2) 0%, rgba(68, 71, 76, 0.2) 100%)',
|
'linear-gradient(180deg, rgba(35, 36, 40, 0.2) 0%, rgba(68, 71, 76, 0.2) 100%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{profile.data?.email_verified ? (
|
{profile.data?.emailVerified ? (
|
||||||
<RiMailCheckLine className="w-6 h-6" />
|
<RiMailCheckLine className="w-6 h-6" />
|
||||||
) : (
|
) : (
|
||||||
<RiMailSendLine className="w-6 h-6" />
|
<RiMailSendLine className="w-6 h-6" />
|
||||||
|
@ -78,10 +78,10 @@ export function EmailVerification({ title, onNext }: StepProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-12 text-center">
|
<h3 className="mt-12 text-center">
|
||||||
{profile.data?.email_verified ? 'Email verified' : title}
|
{profile.data?.emailVerified ? 'Email verified' : title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="text-base text-center">
|
<div className="text-base text-center">
|
||||||
{profile.data?.email_verified ? (
|
{profile.data?.emailVerified ? (
|
||||||
<p className="mt-4 text-gray-50">
|
<p className="mt-4 text-gray-50">
|
||||||
You have successfully verified{' '}
|
You have successfully verified{' '}
|
||||||
<span className="text-gray-25">{profile.data?.email ?? 'your email'}</span>
|
<span className="text-gray-25">{profile.data?.email ?? 'your email'}</span>
|
||||||
|
@ -130,7 +130,7 @@ export function EmailVerification({ title, onNext }: StepProps) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{profile.data?.email_verified && (
|
{profile.data?.emailVerified && (
|
||||||
<Button className="mt-5" fullWidth onClick={onNext}>
|
<Button className="mt-5" fullWidth onClick={onNext}>
|
||||||
Continue setup <RiArrowRightLine className="ml-2 w-5 h-5" />
|
Continue setup <RiArrowRightLine className="ml-2 w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useAuth0 } from '@auth0/auth0-react'
|
import { signOut } from 'next-auth/react'
|
||||||
import { MainContentOverlay, useUserApi } from '@maybe-finance/client/shared'
|
import { MainContentOverlay, useUserApi } from '@maybe-finance/client/shared'
|
||||||
import { LoadingSpinner } from '@maybe-finance/design-system'
|
import { LoadingSpinner } from '@maybe-finance/design-system'
|
||||||
import type { SharedType } from '@maybe-finance/shared'
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
|
@ -22,7 +22,6 @@ function shouldRedirect(path: string, data?: SharedType.UserSubscription) {
|
||||||
|
|
||||||
export function SubscriberGuard({ children }: PropsWithChildren) {
|
export function SubscriberGuard({ children }: PropsWithChildren) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { logout } = useAuth0()
|
|
||||||
const { useSubscription } = useUserApi()
|
const { useSubscription } = useUserApi()
|
||||||
const subscription = useSubscription()
|
const subscription = useSubscription()
|
||||||
|
|
||||||
|
@ -31,7 +30,7 @@ export function SubscriberGuard({ children }: PropsWithChildren) {
|
||||||
<MainContentOverlay
|
<MainContentOverlay
|
||||||
title="Unable to load subscription"
|
title="Unable to load subscription"
|
||||||
actionText="Log out"
|
actionText="Log out"
|
||||||
onAction={() => logout({ logoutParams: { returnTo: window.location.origin } })}
|
onAction={() => signOut()}
|
||||||
>
|
>
|
||||||
<p>Contact us if this issue persists.</p>
|
<p>Contact us if this issue persists.</p>
|
||||||
</MainContentOverlay>
|
</MainContentOverlay>
|
||||||
|
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,16 +1,14 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Controller, useForm } from 'react-hook-form'
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
import { useAuth0 } from '@auth0/auth0-react'
|
import { signOut } from 'next-auth/react'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { AiOutlineLoading3Quarters as LoadingIcon } from 'react-icons/ai'
|
import { AiOutlineLoading3Quarters as LoadingIcon } from 'react-icons/ai'
|
||||||
import {
|
import {
|
||||||
RiAnticlockwise2Line,
|
RiAnticlockwise2Line,
|
||||||
RiAppleFill,
|
|
||||||
RiArrowGoBackFill,
|
RiArrowGoBackFill,
|
||||||
RiDownloadLine,
|
RiDownloadLine,
|
||||||
RiShareForwardLine,
|
RiShareForwardLine,
|
||||||
} from 'react-icons/ri'
|
} from 'react-icons/ri'
|
||||||
import { UserIdentityList } from '../user-details/UserIdentityList'
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
|
@ -30,10 +28,8 @@ import { DeleteUserButton } from './DeleteUserButton'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
|
|
||||||
export function UserDetails() {
|
export function UserDetails() {
|
||||||
const { logout } = useAuth0()
|
const { useProfile, useUpdateProfile } = useUserApi()
|
||||||
const { useProfile, useAuth0Profile, useUpdateProfile } = useUserApi()
|
|
||||||
|
|
||||||
const auth0ProfileQuery = useAuth0Profile()
|
|
||||||
const updateProfileQuery = useUpdateProfile()
|
const updateProfileQuery = useUpdateProfile()
|
||||||
|
|
||||||
const profileQuery = useProfile()
|
const profileQuery = useProfile()
|
||||||
|
@ -77,17 +73,7 @@ export function UserDetails() {
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
{auth0ProfileQuery.data?.primaryIdentity.provider === 'apple' && (
|
|
||||||
<div className="flex items-center gap-x-1 mt-2 text-gray-100">
|
|
||||||
<span className="text-sm">Apple identity</span>
|
|
||||||
<RiAppleFill className="w-3 h-3" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{auth0ProfileQuery.data && (
|
|
||||||
<UserIdentityList profile={auth0ProfileQuery.data} />
|
|
||||||
)}
|
|
||||||
</LoadingPlaceholder>
|
</LoadingPlaceholder>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@ -101,9 +87,7 @@ export function UserDetails() {
|
||||||
Deleting your account is a permanent action. If you delete your account, you
|
Deleting your account is a permanent action. If you delete your account, you
|
||||||
will no longer be able to sign and all data will be deleted.
|
will no longer be able to sign and all data will be deleted.
|
||||||
</p>
|
</p>
|
||||||
<DeleteUserButton
|
<DeleteUserButton onDelete={() => signOut()} />
|
||||||
onDelete={() => logout({ logoutParams: { returnTo: window.location.origin } })}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,2 +1 @@
|
||||||
export * from './UserDetails'
|
export * from './UserDetails'
|
||||||
export * from './LinkAccountFlow'
|
|
||||||
|
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1 +0,0 @@
|
||||||
export * from './NotificationPreferences'
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,47 +1,12 @@
|
||||||
import { useUserApi } from '@maybe-finance/client/shared'
|
import { useUserApi } from '@maybe-finance/client/shared'
|
||||||
import { Button, LoadingSpinner } from '@maybe-finance/design-system'
|
import { Button, LoadingSpinner } from '@maybe-finance/design-system'
|
||||||
import { MultiFactorAuthentication } from './MultiFactorAuthentication'
|
|
||||||
import { PasswordReset } from './PasswordReset'
|
import { PasswordReset } from './PasswordReset'
|
||||||
|
|
||||||
export function SecurityPreferences() {
|
export function SecurityPreferences() {
|
||||||
const { useAuth0Profile } = useUserApi()
|
|
||||||
const profileQuery = useAuth0Profile()
|
|
||||||
|
|
||||||
if (profileQuery.isLoading) {
|
|
||||||
return <LoadingSpinner />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profileQuery.isError) {
|
|
||||||
return (
|
return (
|
||||||
<p className="text-gray-50">
|
|
||||||
Something went wrong loading your security preferences...
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { socialOnlyUser, mfaEnabled } = profileQuery.data
|
|
||||||
|
|
||||||
return socialOnlyUser ? (
|
|
||||||
<>
|
|
||||||
<p className="text-base text-white">
|
|
||||||
Your account credentials are managed by Apple. To reset your password, click the
|
|
||||||
button below to go to your Apple settings.
|
|
||||||
</p>
|
|
||||||
<Button className="mt-4" href="https://appleid.apple.com/">
|
|
||||||
Manage Apple Account
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
<h4 className="mb-2 text-lg uppercase">Password</h4>
|
<h4 className="mb-2 text-lg uppercase">Password</h4>
|
||||||
<PasswordReset />
|
<PasswordReset />
|
||||||
|
|
||||||
<h4 className="mb-2 mt-8 text-lg uppercase">Multi-Factor Authentication</h4>
|
|
||||||
<p className="text-base text-gray-100">
|
|
||||||
Add an extra layer of security by setting up multi-factor authentication. This will
|
|
||||||
need an app like Google Authenticator or Authy.
|
|
||||||
</p>
|
|
||||||
<MultiFactorAuthentication enabled={mfaEnabled} />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1 +0,0 @@
|
||||||
export * from './AuthLoader'
|
|
|
@ -1,5 +1,6 @@
|
||||||
export * from './useAccountApi'
|
export * from './useAccountApi'
|
||||||
export * from './useAccountConnectionApi'
|
export * from './useAccountConnectionApi'
|
||||||
|
export * from './useAuthUserApi'
|
||||||
export * from './useFinicityApi'
|
export * from './useFinicityApi'
|
||||||
export * from './useInstitutionApi'
|
export * from './useInstitutionApi'
|
||||||
export * from './useUserApi'
|
export * from './useUserApi'
|
||||||
|
|
28
libs/client/shared/src/api/useAuthUserApi.ts
Normal file
28
libs/client/shared/src/api/useAuthUserApi.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,20 +1,14 @@
|
||||||
import type { UseMutationOptions, UseQueryOptions } from '@tanstack/react-query'
|
import type { UseMutationOptions, UseQueryOptions } from '@tanstack/react-query'
|
||||||
import type { SharedType } from '@maybe-finance/shared'
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
import type { Auth0ContextInterface } from '@auth0/auth0-react'
|
|
||||||
import type { AxiosInstance } from 'axios'
|
import type { AxiosInstance } from 'axios'
|
||||||
import Axios from 'axios'
|
|
||||||
import * as Sentry from '@sentry/react'
|
import * as Sentry from '@sentry/react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import { useAxiosWithAuth } from '../hooks/useAxiosWithAuth'
|
import { useAxiosWithAuth } from '../hooks/useAxiosWithAuth'
|
||||||
import { useAuth0 } from '@auth0/auth0-react'
|
|
||||||
|
|
||||||
const UserApi = (
|
const UserApi = (axios: AxiosInstance) => ({
|
||||||
axios: AxiosInstance,
|
|
||||||
auth0: Auth0ContextInterface<SharedType.Auth0ReactUser>
|
|
||||||
) => ({
|
|
||||||
async getNetWorthSeries(start: string, end: string) {
|
async getNetWorthSeries(start: string, end: string) {
|
||||||
const { data } = await axios.get<SharedType.NetWorthTimeSeriesResponse>(
|
const { data } = await axios.get<SharedType.NetWorthTimeSeriesResponse>(
|
||||||
`/users/net-worth`,
|
`/users/net-worth`,
|
||||||
|
@ -65,16 +59,8 @@ const UserApi = (
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAuth0Profile() {
|
async getAuthProfile() {
|
||||||
const { data } = await axios.get<SharedType.Auth0Profile>('/users/auth0-profile')
|
const { data } = await axios.get<SharedType.AuthUser>('/users/auth-profile')
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateAuth0Profile(newProfile: SharedType.UpdateAuth0User) {
|
|
||||||
const { data } = await axios.put<
|
|
||||||
SharedType.Auth0User,
|
|
||||||
SharedType.ApiResponse<SharedType.Auth0User>
|
|
||||||
>('/users/auth0-profile', newProfile)
|
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -83,49 +69,6 @@ const UserApi = (
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
async toggleMFA(desiredMFAState: 'enabled' | 'disabled'): Promise<{
|
|
||||||
actualMFAState: 'enabled' | 'disabled'
|
|
||||||
desiredMFAState: 'enabled' | 'disabled'
|
|
||||||
mfaRegistrationComplete: boolean
|
|
||||||
}> {
|
|
||||||
const audience = process.env.NEXT_PUBLIC_AUTH0_AUDIENCE || 'https://maybe-finance-api/v1'
|
|
||||||
|
|
||||||
await axios.put<SharedType.Auth0User, SharedType.ApiResponse<SharedType.Auth0User>>(
|
|
||||||
'/users/auth0-profile',
|
|
||||||
{
|
|
||||||
user_metadata: { enrolled_mfa: desiredMFAState === 'enabled' ? true : false },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// If the user is enabling MFA, prompt them to set it up immediately
|
|
||||||
if (desiredMFAState === 'enabled') {
|
|
||||||
await auth0.loginWithPopup(
|
|
||||||
{
|
|
||||||
authorizationParams: {
|
|
||||||
connection: 'Username-Password-Authentication',
|
|
||||||
screen_hint: 'show-form-only',
|
|
||||||
display: 'page',
|
|
||||||
audience,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutInSeconds: 360 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentIdTokenMFAState = auth0.user?.['https://maybe.co/user-metadata']?.enrolled_mfa
|
|
||||||
? 'enabled'
|
|
||||||
: 'disabled'
|
|
||||||
|
|
||||||
// If the ID token is the same as the user's intended MFA state, that means they successfully
|
|
||||||
// completed the flow. If not, they closed the popup early.
|
|
||||||
return {
|
|
||||||
actualMFAState: currentIdTokenMFAState,
|
|
||||||
desiredMFAState,
|
|
||||||
mfaRegistrationComplete:
|
|
||||||
currentIdTokenMFAState === desiredMFAState || desiredMFAState === 'disabled',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async changePassword(newPassword: SharedType.PasswordReset) {
|
async changePassword(newPassword: SharedType.PasswordReset) {
|
||||||
const { data } = await axios.put<
|
const { data } = await axios.put<
|
||||||
SharedType.PasswordReset,
|
SharedType.PasswordReset,
|
||||||
|
@ -134,36 +77,10 @@ const UserApi = (
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
async linkAccounts({ secondaryJWT, secondaryProvider }: SharedType.LinkAccounts) {
|
async resendEmailVerification(authId?: string) {
|
||||||
try {
|
|
||||||
const { data } = await axios.post<
|
|
||||||
SharedType.LinkAccounts,
|
|
||||||
SharedType.ApiResponse<SharedType.Auth0User>
|
|
||||||
>('/users/link-accounts', { secondaryJWT, secondaryProvider })
|
|
||||||
return data
|
|
||||||
} catch (err) {
|
|
||||||
if (Axios.isAxiosError(err)) {
|
|
||||||
const message = err.response?.data?.errors?.[0]?.title
|
|
||||||
throw new Error(message ?? 'Something went wrong')
|
|
||||||
}
|
|
||||||
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async unlinkAccount(unlinkData: SharedType.UnlinkAccount) {
|
|
||||||
const { data } = await axios.post<
|
|
||||||
SharedType.UnlinkAccount,
|
|
||||||
SharedType.ApiResponse<SharedType.Auth0User>
|
|
||||||
>('/users/unlink-account', unlinkData)
|
|
||||||
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
async resendEmailVerification(auth0Id?: string) {
|
|
||||||
const { data } = await axios.post<{ success: boolean }>(
|
const { data } = await axios.post<{ success: boolean }>(
|
||||||
'/users/resend-verification-email',
|
'/users/resend-verification-email',
|
||||||
{ auth0Id }
|
{ authId }
|
||||||
)
|
)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
@ -201,8 +118,7 @@ const staleTimes = {
|
||||||
export function useUserApi() {
|
export function useUserApi() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { axios } = useAxiosWithAuth()
|
const { axios } = useAxiosWithAuth()
|
||||||
const auth0 = useAuth0()
|
const api = useMemo(() => UserApi(axios), [axios])
|
||||||
const api = useMemo(() => UserApi(axios, auth0), [axios, auth0])
|
|
||||||
|
|
||||||
const useNetWorthSeries = (
|
const useNetWorthSeries = (
|
||||||
{ start, end }: { start: string; end: string },
|
{ start, end }: { start: string; end: string },
|
||||||
|
@ -288,21 +204,14 @@ export function useUserApi() {
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
|
||||||
const useAuth0Profile = (
|
const useAuthProfile = (
|
||||||
options?: Omit<UseQueryOptions<SharedType.Auth0Profile>, 'queryKey' | 'queryFn'>
|
options?: Omit<
|
||||||
) => useQuery(['users', 'auth0-profile'], api.getAuth0Profile, options)
|
UseQueryOptions<SharedType.AuthUser, unknown, SharedType.AuthUser, any[]>,
|
||||||
|
'queryKey' | 'queryFn'
|
||||||
const useUpdateAuth0Profile = (
|
|
||||||
options?: UseMutationOptions<
|
|
||||||
SharedType.Auth0User | undefined,
|
|
||||||
unknown,
|
|
||||||
SharedType.UpdateAuth0User
|
|
||||||
>
|
>
|
||||||
) =>
|
) =>
|
||||||
useMutation(api.updateAuth0Profile, {
|
useQuery(['auth-profile'], api.getAuthProfile, {
|
||||||
onSettled() {
|
staleTime: staleTimes.user,
|
||||||
queryClient.invalidateQueries(['users', 'auth0-profile'])
|
|
||||||
},
|
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -323,33 +232,6 @@ export function useUserApi() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const useLinkAccounts = (
|
|
||||||
options?: UseMutationOptions<
|
|
||||||
SharedType.Auth0User | undefined,
|
|
||||||
unknown,
|
|
||||||
SharedType.LinkAccounts
|
|
||||||
>
|
|
||||||
) => useMutation(api.linkAccounts, options)
|
|
||||||
|
|
||||||
const useUnlinkAccount = (
|
|
||||||
options?: UseMutationOptions<
|
|
||||||
SharedType.Auth0User | undefined,
|
|
||||||
unknown,
|
|
||||||
SharedType.UnlinkAccount
|
|
||||||
>
|
|
||||||
) =>
|
|
||||||
useMutation(api.unlinkAccount, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Account unlinked!')
|
|
||||||
queryClient.invalidateQueries(['users'])
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
Sentry.captureException(err)
|
|
||||||
toast.error('Error unlinking user account')
|
|
||||||
},
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
|
|
||||||
const useResendEmailVerification = (
|
const useResendEmailVerification = (
|
||||||
options?: UseMutationOptions<{ success: boolean } | undefined, unknown, string | undefined>
|
options?: UseMutationOptions<{ success: boolean } | undefined, unknown, string | undefined>
|
||||||
) =>
|
) =>
|
||||||
|
@ -403,12 +285,9 @@ export function useUserApi() {
|
||||||
useCurrentNetWorth,
|
useCurrentNetWorth,
|
||||||
useProfile,
|
useProfile,
|
||||||
useUpdateProfile,
|
useUpdateProfile,
|
||||||
useAuth0Profile,
|
useAuthProfile,
|
||||||
useUpdateAuth0Profile,
|
|
||||||
useSubscription,
|
useSubscription,
|
||||||
useChangePassword,
|
useChangePassword,
|
||||||
useLinkAccounts,
|
|
||||||
useUnlinkAccount,
|
|
||||||
useResendEmailVerification,
|
useResendEmailVerification,
|
||||||
useCreateCheckoutSession,
|
useCreateCheckoutSession,
|
||||||
useCreateCustomerPortalSession,
|
useCreateCustomerPortalSession,
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useAuth0 } from '@auth0/auth0-react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { Button, Dialog } from '@maybe-finance/design-system'
|
import { Button, Dialog } from '@maybe-finance/design-system'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
@ -12,7 +12,7 @@ export interface FeedbackDialogProps {
|
||||||
|
|
||||||
export function FeedbackDialog({ isOpen, onClose, notImplementedNotice }: FeedbackDialogProps) {
|
export function FeedbackDialog({ isOpen, onClose, notImplementedNotice }: FeedbackDialogProps) {
|
||||||
const [feedback, setFeedback] = useState('')
|
const [feedback, setFeedback] = useState('')
|
||||||
const { user } = useAuth0()
|
const { data: session } = useSession()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog isOpen={isOpen} onClose={onClose}>
|
<Dialog isOpen={isOpen} onClose={onClose}>
|
||||||
|
@ -41,10 +41,14 @@ export function FeedbackDialog({ isOpen, onClose, notImplementedNotice }: Feedba
|
||||||
try {
|
try {
|
||||||
await axios
|
await axios
|
||||||
.create({ transformRequest: [(data) => JSON.stringify(data)] })
|
.create({ transformRequest: [(data) => JSON.stringify(data)] })
|
||||||
.post('https://hooks.zapier.com/hooks/catch/10143005/buyo6na/', {
|
.post(
|
||||||
comment: `**From user:** ${user?.sub}\n\n${feedback}`,
|
process.env.NEXT_PUBLIC_ZAPIER_FEEDBACK_HOOK_URL ||
|
||||||
|
'REPLACE_THIS',
|
||||||
|
{
|
||||||
|
comment: `**From user:** ${session?.user?.email}\n\n${feedback}`,
|
||||||
page: `**Main app feedback**: ${window.location.href}`,
|
page: `**Main app feedback**: ${window.location.href}`,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
toast.success('Your feedback was submitted!')
|
toast.success('Your feedback was submitted!')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
@ -1,3 +1,2 @@
|
||||||
export * from './FeedbackDialog'
|
export * from './FeedbackDialog'
|
||||||
export * from './ConfirmDialog'
|
|
||||||
export * from './NonUSDDialog'
|
export * from './NonUSDDialog'
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,11 +1,8 @@
|
||||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||||
import type { SharedType } from '@maybe-finance/shared'
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
import { superjson } from '@maybe-finance/shared'
|
import { superjson } from '@maybe-finance/shared'
|
||||||
import { createContext, type PropsWithChildren, useCallback, useMemo } from 'react'
|
import { createContext, type PropsWithChildren, useMemo } from 'react'
|
||||||
import { useAuth0 } from '@auth0/auth0-react'
|
|
||||||
import Axios from 'axios'
|
import Axios from 'axios'
|
||||||
import * as Sentry from '@sentry/react'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
|
|
||||||
type CreateInstanceOptions = {
|
type CreateInstanceOptions = {
|
||||||
getToken?: () => Promise<string | null>
|
getToken?: () => Promise<string | null>
|
||||||
|
@ -15,7 +12,6 @@ type CreateInstanceOptions = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AxiosContextValue = {
|
export type AxiosContextValue = {
|
||||||
getToken: () => Promise<string | null>
|
|
||||||
defaultBaseUrl: string
|
defaultBaseUrl: string
|
||||||
axios: AxiosInstance
|
axios: AxiosInstance
|
||||||
createInstance: (options?: CreateInstanceOptions) => AxiosInstance
|
createInstance: (options?: CreateInstanceOptions) => AxiosInstance
|
||||||
|
@ -71,63 +67,29 @@ function createInstance(options?: CreateInstanceOptions) {
|
||||||
return instance
|
return instance
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Injects the Auth0 access token into every axios request
|
|
||||||
*
|
|
||||||
* @see https://github.com/auth0/auth0-react/issues/266#issuecomment-919222402
|
|
||||||
*/
|
|
||||||
export function AxiosProvider({ children }: PropsWithChildren) {
|
export function AxiosProvider({ children }: PropsWithChildren) {
|
||||||
// Rather than storing access token in localStorage (insecure), we use this method to retrieve it prior to making API calls
|
|
||||||
const { getAccessTokenSilently, isAuthenticated, loginWithRedirect } = useAuth0()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'
|
||||||
|
|
||||||
const getToken = useCallback(async () => {
|
|
||||||
if (!isAuthenticated) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = await getAccessTokenSilently()
|
|
||||||
return token
|
|
||||||
} catch (err) {
|
|
||||||
const authErr =
|
|
||||||
err && typeof err === 'object' && 'error' in err && typeof err['error'] === 'string'
|
|
||||||
? err['error']
|
|
||||||
: null
|
|
||||||
const isRecoverable = authErr
|
|
||||||
? [
|
|
||||||
'mfa_required',
|
|
||||||
'consent_required',
|
|
||||||
'interaction_required',
|
|
||||||
'login_required',
|
|
||||||
].includes(authErr)
|
|
||||||
: false
|
|
||||||
|
|
||||||
if (isRecoverable) {
|
|
||||||
await loginWithRedirect({ appState: { returnTo: router.asPath } })
|
|
||||||
} else {
|
|
||||||
Sentry.captureException(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, getAccessTokenSilently, loginWithRedirect, router])
|
|
||||||
|
|
||||||
// Expose a default instance with auth, superjson, headers
|
// Expose a default instance with auth, superjson, headers
|
||||||
const defaultInstance = useMemo(() => {
|
const defaultInstance = useMemo(() => {
|
||||||
const defaultHeaders = { 'Content-Type': 'application/json' }
|
const defaultHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Access-Control-Allow-Credentials': true,
|
||||||
|
}
|
||||||
return createInstance({
|
return createInstance({
|
||||||
getToken,
|
axiosOptions: {
|
||||||
axiosOptions: { baseURL: `${API_URL}/v1`, headers: defaultHeaders },
|
baseURL: `${API_URL}/v1`,
|
||||||
|
headers: defaultHeaders,
|
||||||
|
withCredentials: true,
|
||||||
|
},
|
||||||
serialize: true,
|
serialize: true,
|
||||||
deserialize: true,
|
deserialize: true,
|
||||||
})
|
})
|
||||||
}, [getToken, API_URL])
|
}, [API_URL])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AxiosContext.Provider
|
<AxiosContext.Provider
|
||||||
value={{
|
value={{
|
||||||
getToken,
|
|
||||||
defaultBaseUrl: `${API_URL}/v1`,
|
defaultBaseUrl: `${API_URL}/v1`,
|
||||||
axios: defaultInstance,
|
axios: defaultInstance,
|
||||||
createInstance,
|
createInstance,
|
||||||
|
|
|
@ -5,4 +5,3 @@ export * from './QueryProvider'
|
||||||
export * from './AccountContextProvider'
|
export * from './AccountContextProvider'
|
||||||
export * from './UserAccountContextProvider'
|
export * from './UserAccountContextProvider'
|
||||||
export * from './PopoutProvider'
|
export * from './PopoutProvider'
|
||||||
export * from './AuthProvider'
|
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -1,7 +1,23 @@
|
||||||
import type { ImageLoaderProps } from 'next/legacy/image'
|
import type { ImageLoaderProps } from 'next/legacy/image'
|
||||||
|
|
||||||
|
function isJSON(str: string): boolean {
|
||||||
|
try {
|
||||||
|
JSON.parse(str)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function enhancerizerLoader({ src, width }: ImageLoaderProps): string {
|
export function enhancerizerLoader({ src, width }: ImageLoaderProps): string {
|
||||||
const parsed = JSON.parse(src) as { [key: string]: string | number }
|
let parsed: { [key: string]: string | number }
|
||||||
|
|
||||||
|
if (isJSON(src)) {
|
||||||
|
parsed = JSON.parse(src)
|
||||||
|
} else {
|
||||||
|
parsed = { src }
|
||||||
|
}
|
||||||
|
|
||||||
parsed.width ??= width
|
parsed.width ??= width
|
||||||
parsed.height ??= width
|
parsed.height ??= width
|
||||||
|
|
||||||
|
|
|
@ -2,4 +2,3 @@ export * from './image-loaders'
|
||||||
export * from './browser-utils'
|
export * from './browser-utils'
|
||||||
export * from './account-utils'
|
export * from './account-utils'
|
||||||
export * from './form-utils'
|
export * from './form-utils'
|
||||||
export * from './auth-utils'
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Dispatch, MouseEventHandler, PropsWithChildren, SetStateAction } from 'react'
|
import type { Dispatch, MouseEventHandler, PropsWithChildren, SetStateAction } from 'react'
|
||||||
import type { IconType } from 'react-icons'
|
import type { IconType } from 'react-icons'
|
||||||
import type { PopperProps } from 'react-popper'
|
import type { PopperProps } from 'react-popper'
|
||||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
import React, { createContext, useContext, useState, useEffect, useRef } from 'react'
|
||||||
import { Listbox as HeadlessListbox } from '@headlessui/react'
|
import { Listbox as HeadlessListbox } from '@headlessui/react'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { RiArrowDownSFill, RiCheckFill } from 'react-icons/ri'
|
import { RiArrowDownSFill, RiCheckFill } from 'react-icons/ri'
|
||||||
|
@ -166,6 +166,8 @@ function Options({
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null)
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null)
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
const isOpenRef = useRef(false)
|
||||||
|
|
||||||
const { styles, attributes, update } = usePopper(referenceElement, popperElement, {
|
const { styles, attributes, update } = usePopper(referenceElement, popperElement, {
|
||||||
placement,
|
placement,
|
||||||
modifiers: [
|
modifiers: [
|
||||||
|
@ -180,6 +182,7 @@ function Options({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && update) update()
|
if (isOpen && update) update()
|
||||||
|
if (isOpenRef.current !== isOpen) setIsOpen(isOpenRef.current)
|
||||||
}, [isOpen, update])
|
}, [isOpen, update])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -198,7 +201,7 @@ function Options({
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{({ open }) => {
|
{({ open }) => {
|
||||||
setIsOpen(open)
|
isOpenRef.current = open
|
||||||
return children
|
return children
|
||||||
}}
|
}}
|
||||||
</HeadlessListbox.Options>
|
</HeadlessListbox.Options>
|
||||||
|
|
|
@ -46,6 +46,8 @@ function Items({
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null)
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null)
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
const isOpenRef = useRef(false)
|
||||||
|
|
||||||
const { styles, attributes, update } = usePopper(referenceElement?.current, popperElement, {
|
const { styles, attributes, update } = usePopper(referenceElement?.current, popperElement, {
|
||||||
placement,
|
placement,
|
||||||
modifiers: [
|
modifiers: [
|
||||||
|
@ -60,6 +62,7 @@ function Items({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && update) update()
|
if (isOpen && update) update()
|
||||||
|
if (isOpenRef.current !== isOpen) setIsOpen(isOpenRef.current)
|
||||||
}, [isOpen, update])
|
}, [isOpen, update])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -75,7 +78,7 @@ function Items({
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{(renderProps) => {
|
{(renderProps) => {
|
||||||
setIsOpen(renderProps.open)
|
isOpenRef.current = renderProps.open
|
||||||
return typeof children === 'function' ? children(renderProps) : children
|
return typeof children === 'function' ? children(renderProps) : children
|
||||||
}}
|
}}
|
||||||
</HeadlessMenu.Items>
|
</HeadlessMenu.Items>
|
||||||
|
|
52
libs/server/features/src/auth-user/auth-user.service.ts
Normal file
52
libs/server/features/src/auth-user/auth-user.service.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
1
libs/server/features/src/auth-user/index.ts
Normal file
1
libs/server/features/src/auth-user/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './auth-user.service'
|
|
@ -2,7 +2,6 @@ import type { Logger } from 'winston'
|
||||||
import type { PrismaClient } from '@prisma/client'
|
import type { PrismaClient } from '@prisma/client'
|
||||||
import type { SendEmailQueueJobData } from '@maybe-finance/server/shared'
|
import type { SendEmailQueueJobData } from '@maybe-finance/server/shared'
|
||||||
import type { IEmailService } from './email.service'
|
import type { IEmailService } from './email.service'
|
||||||
import type { ManagementClient } from 'auth0'
|
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
|
|
||||||
export interface IEmailProcessor {
|
export interface IEmailProcessor {
|
||||||
|
@ -14,7 +13,6 @@ export class EmailProcessor implements IEmailProcessor {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly prisma: PrismaClient,
|
private readonly prisma: PrismaClient,
|
||||||
private readonly auth0: ManagementClient,
|
|
||||||
private readonly emailService: IEmailService
|
private readonly emailService: IEmailService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ export * from './account-balance'
|
||||||
export * from './email'
|
export * from './email'
|
||||||
export * from './institution'
|
export * from './institution'
|
||||||
export * from './security-pricing'
|
export * from './security-pricing'
|
||||||
|
export * from './auth-user'
|
||||||
export * from './user'
|
export * from './user'
|
||||||
export * from './valuation'
|
export * from './valuation'
|
||||||
export * from './providers'
|
export * from './providers'
|
||||||
|
|
|
@ -27,7 +27,7 @@ export class StripeWebhookHandler implements IStripeWebhookHandler {
|
||||||
|
|
||||||
await this.prisma.user.updateMany({
|
await this.prisma.user.updateMany({
|
||||||
where: {
|
where: {
|
||||||
auth0Id: session.client_reference_id,
|
authId: session.client_reference_id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
trialEnd: null,
|
trialEnd: null,
|
||||||
|
|
|
@ -1,16 +1,6 @@
|
||||||
import type {
|
import type { AccountCategory, AccountType, PrismaClient, User } from '@prisma/client'
|
||||||
AccountCategory,
|
|
||||||
AccountType,
|
|
||||||
PrismaClient,
|
|
||||||
User,
|
|
||||||
} from '@prisma/client'
|
|
||||||
import type { Logger } from 'winston'
|
import type { Logger } from 'winston'
|
||||||
import {
|
import type { PurgeUserQueue, SyncUserQueue } from '@maybe-finance/server/shared'
|
||||||
AuthUtil,
|
|
||||||
type PurgeUserQueue,
|
|
||||||
type SyncUserQueue,
|
|
||||||
} from '@maybe-finance/server/shared'
|
|
||||||
import type { ManagementClient, UnlinkAccountsParamsProvider } from 'auth0'
|
|
||||||
import type Stripe from 'stripe'
|
import type Stripe from 'stripe'
|
||||||
import type { IBalanceSyncStrategyFactory } from '../account-balance'
|
import type { IBalanceSyncStrategyFactory } from '../account-balance'
|
||||||
import type { IAccountQueryService } from '../account'
|
import type { IAccountQueryService } from '../account'
|
||||||
|
@ -64,7 +54,6 @@ export class UserService implements IUserService {
|
||||||
private readonly balanceSyncStrategyFactory: IBalanceSyncStrategyFactory,
|
private readonly balanceSyncStrategyFactory: IBalanceSyncStrategyFactory,
|
||||||
private readonly syncQueue: SyncUserQueue,
|
private readonly syncQueue: SyncUserQueue,
|
||||||
private readonly purgeQueue: PurgeUserQueue,
|
private readonly purgeQueue: PurgeUserQueue,
|
||||||
private readonly auth0: ManagementClient,
|
|
||||||
private readonly stripe: Stripe
|
private readonly stripe: Stripe
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@ -74,46 +63,11 @@ export class UserService implements IUserService {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAuth0Profile(user: User): Promise<SharedType.Auth0Profile> {
|
async getAuthProfile(id: User['id']): Promise<SharedType.AuthUser> {
|
||||||
if (!user.email) throw new Error('No email found for user')
|
const user = await this.get(id)
|
||||||
|
return this.prisma.authUser.findUniqueOrThrow({
|
||||||
const usersWithMatchingEmail = await this.auth0.getUsersByEmail(user.email)
|
where: { id: user.authId },
|
||||||
const autoPromptEnabled = user.linkAccountDismissedAt == null
|
})
|
||||||
const currentUser = usersWithMatchingEmail.find((u) => u.user_id === user.auth0Id)
|
|
||||||
const primaryIdentity = currentUser?.identities?.find(
|
|
||||||
(identity) => !('profileData' in identity)
|
|
||||||
)
|
|
||||||
const secondaryIdentities =
|
|
||||||
currentUser?.identities?.filter((identity) => 'profileData' in identity) ?? []
|
|
||||||
if (!currentUser || !primaryIdentity) throw new Error('Failed to get Auth0 user')
|
|
||||||
|
|
||||||
const socialOnlyUser =
|
|
||||||
primaryIdentity.isSocial && secondaryIdentities.every((i) => i.isSocial)
|
|
||||||
|
|
||||||
const suggestedIdentities = usersWithMatchingEmail
|
|
||||||
.filter(
|
|
||||||
(match) =>
|
|
||||||
match.email_verified &&
|
|
||||||
match.user_id !== user.auth0Id &&
|
|
||||||
match.identities?.at(0) != null
|
|
||||||
)
|
|
||||||
.map((user) => user.identities!.at(0)!)
|
|
||||||
|
|
||||||
// Auth0 returns 'true' (mis-typing) or true, so normalize the type here
|
|
||||||
const email_verified =
|
|
||||||
(currentUser.email_verified as unknown as string) === 'true' ||
|
|
||||||
currentUser.email_verified === true
|
|
||||||
|
|
||||||
return {
|
|
||||||
...currentUser,
|
|
||||||
email_verified,
|
|
||||||
primaryIdentity,
|
|
||||||
secondaryIdentities,
|
|
||||||
suggestedIdentities,
|
|
||||||
socialOnlyUser,
|
|
||||||
autoPromptEnabled,
|
|
||||||
mfaEnabled: currentUser.user_metadata?.enrolled_mfa === true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async sync(id: User['id']) {
|
async sync(id: User['id']) {
|
||||||
|
@ -166,9 +120,10 @@ export class UserService implements IUserService {
|
||||||
// Delete Stripe customer, ending any active subscriptions
|
// Delete Stripe customer, ending any active subscriptions
|
||||||
if (user.stripeCustomerId) await this.stripe.customers.del(user.stripeCustomerId)
|
if (user.stripeCustomerId) await this.stripe.customers.del(user.stripeCustomerId)
|
||||||
|
|
||||||
// Delete user from Auth0 so that it cannot be accessed in a partially-purged state
|
// Delete user from Auth so that it cannot be accessed in a partially-purged state
|
||||||
this.logger.info(`Removing user ${user.id} from Auth0 (${user.auth0Id})`)
|
// TODO: Update this to use new Auth
|
||||||
await this.auth0.deleteUser({ id: user.auth0Id })
|
this.logger.info(`Removing user ${user.id} from Auth (${user.authId})`)
|
||||||
|
await this.prisma.authUser.delete({ where: { id: user.authId } })
|
||||||
|
|
||||||
await this.purgeQueue.add('purge-user', { userId: user.id })
|
await this.purgeQueue.add('purge-user', { userId: user.id })
|
||||||
|
|
||||||
|
@ -321,7 +276,7 @@ export class UserService implements IUserService {
|
||||||
const user = await this.prisma.user.findUniqueOrThrow({
|
const user = await this.prisma.user.findUniqueOrThrow({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: {
|
select: {
|
||||||
auth0Id: true,
|
authId: true,
|
||||||
onboarding: true,
|
onboarding: true,
|
||||||
dob: true,
|
dob: true,
|
||||||
household: true,
|
household: true,
|
||||||
|
@ -336,10 +291,12 @@ export class UserService implements IUserService {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const auth0User = await this.auth0.getUser({ id: user.auth0Id })
|
const authUser = await this.prisma.authUser.findUniqueOrThrow({
|
||||||
|
where: { id: user.authId },
|
||||||
|
})
|
||||||
|
|
||||||
// Auth0 has this mis-typed and it comes in as a 'true' string
|
// NextAuth used DateTime for this field
|
||||||
const email_verified = auth0User.email_verified as unknown as string | boolean
|
const email_verified = authUser.emailVerified === null ? false : true
|
||||||
|
|
||||||
const typedOnboarding = user.onboarding as OnboardingState | null
|
const typedOnboarding = user.onboarding as OnboardingState | null
|
||||||
const onboardingState = typedOnboarding
|
const onboardingState = typedOnboarding
|
||||||
|
@ -350,8 +307,8 @@ export class UserService implements IUserService {
|
||||||
{
|
{
|
||||||
...user,
|
...user,
|
||||||
onboarding: onboardingState,
|
onboarding: onboardingState,
|
||||||
emailVerified: email_verified === true || email_verified === 'true',
|
emailVerified: email_verified,
|
||||||
isAppleIdentity: auth0User.identities?.[0].provider === 'apple',
|
isAppleIdentity: false,
|
||||||
},
|
},
|
||||||
onboardingState.markedComplete
|
onboardingState.markedComplete
|
||||||
)
|
)
|
||||||
|
@ -375,7 +332,7 @@ export class UserService implements IUserService {
|
||||||
.setTitle((_) => "Before we start, let's verify your email")
|
.setTitle((_) => "Before we start, let's verify your email")
|
||||||
.addToGroup('setup')
|
.addToGroup('setup')
|
||||||
.completeIf((user) => user.emailVerified)
|
.completeIf((user) => user.emailVerified)
|
||||||
.excludeIf((user) => user.isAppleIdentity) // Auth0 auto-verifies Apple identities.
|
.excludeIf((user) => user.isAppleIdentity || true) // TODO: Needs email service to send, skip for now
|
||||||
|
|
||||||
onboarding
|
onboarding
|
||||||
.addStep('firstAccount')
|
.addStep('firstAccount')
|
||||||
|
@ -551,45 +508,4 @@ export class UserService implements IUserService {
|
||||||
|
|
||||||
return onboarding
|
return onboarding
|
||||||
}
|
}
|
||||||
|
|
||||||
async linkAccounts(
|
|
||||||
primaryAuth0Id: User['auth0Id'],
|
|
||||||
provider: string,
|
|
||||||
secondaryJWT: { token: string; domain: string; audience: string }
|
|
||||||
) {
|
|
||||||
const validatedJWT = await AuthUtil.validateRS256JWT(
|
|
||||||
`Bearer ${secondaryJWT.token}`,
|
|
||||||
secondaryJWT.domain,
|
|
||||||
secondaryJWT.audience
|
|
||||||
)
|
|
||||||
|
|
||||||
const user = await this.prisma.user.findFirst({ where: { auth0Id: validatedJWT.auth0Id } })
|
|
||||||
|
|
||||||
if (user?.stripePriceId) {
|
|
||||||
throw new Error(
|
|
||||||
'The account you are trying to link has an active Stripe trial or subscription. We cannot link this identity at this time.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.auth0.linkUsers(primaryAuth0Id, {
|
|
||||||
user_id: validatedJWT.auth0Id,
|
|
||||||
provider,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async unlinkAccounts(
|
|
||||||
primaryAuth0Id: User['auth0Id'],
|
|
||||||
secondaryAuth0Id: User['auth0Id'],
|
|
||||||
secondaryProvider: UnlinkAccountsParamsProvider
|
|
||||||
) {
|
|
||||||
const response = await this.auth0.unlinkUsers({
|
|
||||||
id: primaryAuth0Id,
|
|
||||||
provider: secondaryProvider,
|
|
||||||
user_id: secondaryAuth0Id,
|
|
||||||
})
|
|
||||||
|
|
||||||
this.logger.info(`Unlinked ${secondaryAuth0Id} from ${primaryAuth0Id}`)
|
|
||||||
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@ export async function validateRS256JWT(
|
||||||
domain: string,
|
domain: string,
|
||||||
audience: string
|
audience: string
|
||||||
): Promise<{
|
): Promise<{
|
||||||
auth0Id: string
|
authId: string
|
||||||
userMetadata: SharedType.MaybeUserMetadata
|
userMetadata: SharedType.MaybeUserMetadata
|
||||||
appMetadata: SharedType.MaybeAppMetadata
|
appMetadata: SharedType.MaybeAppMetadata
|
||||||
}> {
|
}> {
|
||||||
|
@ -50,7 +50,7 @@ export async function validateRS256JWT(
|
||||||
if (typeof payload !== 'object') return reject('payload not an object')
|
if (typeof payload !== 'object') return reject('payload not an object')
|
||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
auth0Id: payload.sub!,
|
authId: payload.sub!,
|
||||||
appMetadata: payload[SharedType.Auth0CustomNamespace.AppMetadata] ?? {},
|
appMetadata: payload[SharedType.Auth0CustomNamespace.AppMetadata] ?? {},
|
||||||
userMetadata: payload[SharedType.Auth0CustomNamespace.UserMetadata] ?? {},
|
userMetadata: payload[SharedType.Auth0CustomNamespace.UserMetadata] ?? {},
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import type { User as Auth0UserClient } from '@auth0/auth0-react'
|
|
||||||
import type { Identity, User as Auth0UserServer } from 'auth0'
|
|
||||||
import type {
|
import type {
|
||||||
AccountCategory,
|
AccountCategory,
|
||||||
AccountClassification,
|
AccountClassification,
|
||||||
|
@ -7,6 +5,7 @@ import type {
|
||||||
Prisma,
|
Prisma,
|
||||||
Security,
|
Security,
|
||||||
User as PrismaUser,
|
User as PrismaUser,
|
||||||
|
AuthUser,
|
||||||
} from '@prisma/client'
|
} from '@prisma/client'
|
||||||
import type { Institution } from 'plaid'
|
import type { Institution } from 'plaid'
|
||||||
import type { TimeSeries, TimeSeriesResponseWithDetail, Trend } from './general-types'
|
import type { TimeSeries, TimeSeriesResponseWithDetail, Trend } from './general-types'
|
||||||
|
@ -27,6 +26,14 @@ export type UpdateUser = Partial<
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================================================
|
||||||
|
* ====== Auth User ======
|
||||||
|
* ================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type { AuthUser }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ================================================================
|
* ================================================================
|
||||||
* ====== Net Worth ======
|
* ====== Net Worth ======
|
||||||
|
@ -182,39 +189,11 @@ export type MaybeCustomClaims = {
|
||||||
[Auth0CustomNamespace.PrimaryIdentity]?: PrimaryAuth0Identity
|
[Auth0CustomNamespace.PrimaryIdentity]?: PrimaryAuth0Identity
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Auth0ReactUser = Auth0UserClient & MaybeCustomClaims
|
|
||||||
export type Auth0User = Auth0UserServer<MaybeAppMetadata, MaybeUserMetadata>
|
|
||||||
export type Auth0Profile = Auth0User & {
|
|
||||||
primaryIdentity: Identity // actual
|
|
||||||
secondaryIdentities: Identity[] // linked
|
|
||||||
suggestedIdentities: Identity[] // potential links
|
|
||||||
autoPromptEnabled: boolean
|
|
||||||
socialOnlyUser: boolean
|
|
||||||
mfaEnabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UpdateAuth0User = { enrolled_mfa: boolean }
|
|
||||||
|
|
||||||
export interface PasswordReset {
|
export interface PasswordReset {
|
||||||
currentPassword: string
|
currentPassword: string
|
||||||
newPassword: string
|
newPassword: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LinkAccountStatus = {
|
|
||||||
autoPromptEnabled: boolean
|
|
||||||
suggestedUsers: Auth0User[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LinkAccounts {
|
|
||||||
secondaryJWT: string
|
|
||||||
secondaryProvider: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UnlinkAccount {
|
|
||||||
secondaryAuth0Id: string
|
|
||||||
secondaryProvider: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UserSubscription = {
|
export type UserSubscription = {
|
||||||
subscribed: boolean
|
subscribed: boolean
|
||||||
trialing: boolean
|
trialing: boolean
|
||||||
|
|
11
package.json
11
package.json
|
@ -26,10 +26,6 @@
|
||||||
"dev:docker:reset": "docker-compose down -v --rmi all --remove-orphans && docker system prune --all --volumes && docker-compose build",
|
"dev:docker:reset": "docker-compose down -v --rmi all --remove-orphans && docker system prune --all --volumes && docker-compose build",
|
||||||
"dev:circular": "npx madge --circular --extensions ts libs",
|
"dev:circular": "npx madge --circular --extensions ts libs",
|
||||||
"analyze:client": "ANALYZE=true nx build client --skip-nx-cache",
|
"analyze:client": "ANALYZE=true nx build client --skip-nx-cache",
|
||||||
"auth0:sync": "node auth0/sync",
|
|
||||||
"auth0:deploy": "node auth0/deploy",
|
|
||||||
"auth0:test": "auth0 test login 7MtD6RWsXKInGPrFyeEseo7Y8PXSBEiV --tenant maybe-finance-development.us.auth0.com --force",
|
|
||||||
"auth0:edit": "live-server auth0",
|
|
||||||
"tools:pages": "live-server tools/pages",
|
"tools:pages": "live-server tools/pages",
|
||||||
"prepare": "husky install"
|
"prepare": "husky install"
|
||||||
},
|
},
|
||||||
|
@ -38,8 +34,7 @@
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth0/auth0-react": "^2.0.0",
|
"@auth/prisma-adapter": "^1.0.14",
|
||||||
"@auth0/nextjs-auth0": "^2.0.0",
|
|
||||||
"@bull-board/express": "^4.6.4",
|
"@bull-board/express": "^4.6.4",
|
||||||
"@casl/ability": "^6.3.2",
|
"@casl/ability": "^6.3.2",
|
||||||
"@casl/prisma": "^1.4.1",
|
"@casl/prisma": "^1.4.1",
|
||||||
|
@ -93,8 +88,10 @@
|
||||||
"auth0-deploy-cli": "^7.15.1",
|
"auth0-deploy-cli": "^7.15.1",
|
||||||
"autoprefixer": "10.4.13",
|
"autoprefixer": "10.4.13",
|
||||||
"axios": "^0.26.1",
|
"axios": "^0.26.1",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
"bull": "^4.10.2",
|
"bull": "^4.10.2",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
"core-js": "^3.6.5",
|
"core-js": "^3.6.5",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"crypto-js": "^4.1.1",
|
"crypto-js": "^4.1.1",
|
||||||
|
@ -122,6 +119,7 @@
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"next": "13.1.1",
|
"next": "13.1.1",
|
||||||
|
"next-auth": "^4.24.5",
|
||||||
"pg": "^8.8.0",
|
"pg": "^8.8.0",
|
||||||
"plaid": "^12.1.0",
|
"plaid": "^12.1.0",
|
||||||
"postcss": "8.4.19",
|
"postcss": "8.4.19",
|
||||||
|
@ -195,6 +193,7 @@
|
||||||
"@testing-library/react": "13.4.0",
|
"@testing-library/react": "13.4.0",
|
||||||
"@testing-library/user-event": "^13.2.1",
|
"@testing-library/user-event": "^13.2.1",
|
||||||
"@types/auth0": "^2.35.7",
|
"@types/auth0": "^2.35.7",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/cors": "^2.8.12",
|
"@types/cors": "^2.8.12",
|
||||||
"@types/crypto-js": "^4.1.1",
|
"@types/crypto-js": "^4.1.1",
|
||||||
"@types/d3-array": "^3.0.3",
|
"@types/d3-array": "^3.0.3",
|
||||||
|
|
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "auth_user" ADD COLUMN "password" TEXT;
|
|
@ -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";
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "auth_user" ADD COLUMN "first_name" TEXT,
|
||||||
|
ADD COLUMN "last_name" TEXT;
|
|
@ -395,7 +395,7 @@ model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
auth0Id String @unique @map("auth0_id")
|
authId String @unique @map("auth_id") // NextAuth user id
|
||||||
|
|
||||||
// profile
|
// profile
|
||||||
email String @db.Citext
|
email String @db.Citext
|
||||||
|
@ -560,6 +560,61 @@ model PlanMilestone {
|
||||||
@@map("plan_milestone")
|
@@map("plan_milestone")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NextAuth Models
|
||||||
|
model AuthAccount {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
type String
|
||||||
|
provider String
|
||||||
|
providerAccountId String @map("provider_account_id")
|
||||||
|
refresh_token String? @db.Text
|
||||||
|
access_token String? @db.Text
|
||||||
|
expires_at Int?
|
||||||
|
token_type String?
|
||||||
|
scope String?
|
||||||
|
id_token String? @db.Text
|
||||||
|
session_state String?
|
||||||
|
|
||||||
|
user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([provider, providerAccountId])
|
||||||
|
@@map("auth_account")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuthUser {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String?
|
||||||
|
firstName String? @map("first_name")
|
||||||
|
lastName String? @map("last_name")
|
||||||
|
email String? @unique
|
||||||
|
emailVerified DateTime? @map("email_verified")
|
||||||
|
password String?
|
||||||
|
image String?
|
||||||
|
accounts AuthAccount[]
|
||||||
|
sessions AuthSession[]
|
||||||
|
|
||||||
|
@@map("auth_user")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuthSession {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionToken String @unique @map("session_token")
|
||||||
|
userId String @map("user_id")
|
||||||
|
expires DateTime
|
||||||
|
user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("auth_session")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuthVerificationToken {
|
||||||
|
identifier String
|
||||||
|
token String @unique
|
||||||
|
expires DateTime
|
||||||
|
|
||||||
|
@@unique([identifier, token])
|
||||||
|
@@map("auth_verification_token")
|
||||||
|
}
|
||||||
|
|
||||||
enum ApprovalStatus {
|
enum ApprovalStatus {
|
||||||
pending
|
pending
|
||||||
approved
|
approved
|
||||||
|
|
238
yarn.lock
238
yarn.lock
|
@ -9,32 +9,25 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@jridgewell/trace-mapping" "^0.3.0"
|
"@jridgewell/trace-mapping" "^0.3.0"
|
||||||
|
|
||||||
"@auth0/auth0-react@^2.0.0":
|
"@auth/core@0.20.0":
|
||||||
version "2.0.0"
|
version "0.20.0"
|
||||||
resolved "https://registry.yarnpkg.com/@auth0/auth0-react/-/auth0-react-2.0.0.tgz#74e4d3662896e71dd95cca70b395715825da3b4e"
|
resolved "https://registry.yarnpkg.com/@auth/core/-/core-0.20.0.tgz#18b706b9708973b1fd4cb6aac5ef47d89201f925"
|
||||||
integrity sha512-3pf41wU6ksm/6uPYAwjX5bZ7ma/K4LethibagTrKkMPuS8UatBvxLDtl3Aq52ZlJi1I+I42ckEfzWqloNxssIg==
|
integrity sha512-04lQH58H5d/9xQ63MOTDTOC7sXWYlr/RhJ97wfFLXzll7nYyCKbkrT3ZMdzdLC5O+qt90sQDK85TAtLlcZ2WBg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@auth0/auth0-spa-js" "^2.0.2"
|
"@panva/hkdf" "^1.1.1"
|
||||||
|
"@types/cookie" "0.6.0"
|
||||||
|
cookie "0.6.0"
|
||||||
|
jose "^5.1.3"
|
||||||
|
oauth4webapi "^2.4.0"
|
||||||
|
preact "10.11.3"
|
||||||
|
preact-render-to-string "5.2.3"
|
||||||
|
|
||||||
"@auth0/auth0-spa-js@^2.0.2":
|
"@auth/prisma-adapter@^1.0.14":
|
||||||
version "2.0.2"
|
version "1.0.14"
|
||||||
resolved "https://registry.yarnpkg.com/@auth0/auth0-spa-js/-/auth0-spa-js-2.0.2.tgz#fe0d5eeb6f0da48c24913a07b3565d48792de6d5"
|
resolved "https://registry.yarnpkg.com/@auth/prisma-adapter/-/prisma-adapter-1.0.14.tgz#3b46f86beec618ab5d6756fdd1b520535cf010ac"
|
||||||
integrity sha512-sxK9Lb6gXGImqjmWBfndA/OSNY4YLPTPwJEVuitXIOZ1p3EoqHM4zjIHvcdiYIaVo+cUfEf3l0bf8UA7Xi4tjg==
|
integrity sha512-7urwnDT+K81SocU0SbfY/vtY/NbXgj8/AU2k6Ek8waHT/7YPLsOQnXQsTWROmolFshNVkt2kq9Z/HOVnRdHrkQ==
|
||||||
|
|
||||||
"@auth0/nextjs-auth0@^2.0.0":
|
|
||||||
version "2.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/@auth0/nextjs-auth0/-/nextjs-auth0-2.0.0.tgz#6695c6eb0d657f4ee6e4234891d3cb50ba0893d1"
|
|
||||||
integrity sha512-LwV3AqJh0CXzzM1vUgSVKvYcZQsp3NzV4xboBCwvfzz7DIWVu1Ge1v0uQBGnzl3XDtFFQNYqVGjcPw5Wu1/L6A==
|
|
||||||
dependencies:
|
dependencies:
|
||||||
"@panva/hkdf" "^1.0.2"
|
"@auth/core" "0.20.0"
|
||||||
cookie "^0.5.0"
|
|
||||||
debug "^4.3.4"
|
|
||||||
http-errors "^1.8.1"
|
|
||||||
joi "^17.6.0"
|
|
||||||
jose "^4.9.2"
|
|
||||||
openid-client "^5.2.1"
|
|
||||||
tslib "^2.4.0"
|
|
||||||
url-join "^4.0.1"
|
|
||||||
|
|
||||||
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.16.7", "@babel/code-frame@^7.18.6", "@babel/code-frame@^7.5.5", "@babel/code-frame@^7.8.3":
|
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.16.7", "@babel/code-frame@^7.18.6", "@babel/code-frame@^7.5.5", "@babel/code-frame@^7.8.3":
|
||||||
version "7.18.6"
|
version "7.18.6"
|
||||||
|
@ -1464,6 +1457,13 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
regenerator-runtime "^0.13.10"
|
regenerator-runtime "^0.13.10"
|
||||||
|
|
||||||
|
"@babel/runtime@^7.20.13":
|
||||||
|
version "7.23.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.8.tgz#8ee6fe1ac47add7122902f257b8ddf55c898f650"
|
||||||
|
integrity sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==
|
||||||
|
dependencies:
|
||||||
|
regenerator-runtime "^0.14.0"
|
||||||
|
|
||||||
"@babel/template@^7.12.7", "@babel/template@^7.16.7", "@babel/template@^7.18.6":
|
"@babel/template@^7.12.7", "@babel/template@^7.16.7", "@babel/template@^7.18.6":
|
||||||
version "7.18.6"
|
version "7.18.6"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.6.tgz#1283f4993e00b929d6e2d3c72fdc9168a2977a31"
|
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.6.tgz#1283f4993e00b929d6e2d3c72fdc9168a2977a31"
|
||||||
|
@ -2162,6 +2162,21 @@
|
||||||
resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b"
|
resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b"
|
||||||
integrity sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==
|
integrity sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==
|
||||||
|
|
||||||
|
"@mapbox/node-pre-gyp@^1.0.11":
|
||||||
|
version "1.0.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa"
|
||||||
|
integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==
|
||||||
|
dependencies:
|
||||||
|
detect-libc "^2.0.0"
|
||||||
|
https-proxy-agent "^5.0.0"
|
||||||
|
make-dir "^3.1.0"
|
||||||
|
node-fetch "^2.6.7"
|
||||||
|
nopt "^5.0.0"
|
||||||
|
npmlog "^5.0.1"
|
||||||
|
rimraf "^3.0.2"
|
||||||
|
semver "^7.3.5"
|
||||||
|
tar "^6.1.11"
|
||||||
|
|
||||||
"@mdx-js/mdx@^1.6.22":
|
"@mdx-js/mdx@^1.6.22":
|
||||||
version "1.6.22"
|
version "1.6.22"
|
||||||
resolved "https://registry.yarnpkg.com/@mdx-js/mdx/-/mdx-1.6.22.tgz#8a723157bf90e78f17dc0f27995398e6c731f1ba"
|
resolved "https://registry.yarnpkg.com/@mdx-js/mdx/-/mdx-1.6.22.tgz#8a723157bf90e78f17dc0f27995398e6c731f1ba"
|
||||||
|
@ -2696,6 +2711,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@panva/hkdf/-/hkdf-1.0.2.tgz#bab0f09d09de9fd83628220d496627681bc440d6"
|
resolved "https://registry.yarnpkg.com/@panva/hkdf/-/hkdf-1.0.2.tgz#bab0f09d09de9fd83628220d496627681bc440d6"
|
||||||
integrity sha512-MSAs9t3Go7GUkMhpKC44T58DJ5KGk2vBo+h1cqQeqlMfdGkxaVB78ZWpv9gYi/g2fa4sopag9gJsNvS8XGgWJA==
|
integrity sha512-MSAs9t3Go7GUkMhpKC44T58DJ5KGk2vBo+h1cqQeqlMfdGkxaVB78ZWpv9gYi/g2fa4sopag9gJsNvS8XGgWJA==
|
||||||
|
|
||||||
|
"@panva/hkdf@^1.1.1":
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@panva/hkdf/-/hkdf-1.1.1.tgz#ab9cd8755d1976e72fc77a00f7655a64efe6cd5d"
|
||||||
|
integrity sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA==
|
||||||
|
|
||||||
"@parcel/watcher@2.0.4":
|
"@parcel/watcher@2.0.4":
|
||||||
version "2.0.4"
|
version "2.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.0.4.tgz#f300fef4cc38008ff4b8c29d92588eced3ce014b"
|
resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.0.4.tgz#f300fef4cc38008ff4b8c29d92588eced3ce014b"
|
||||||
|
@ -4407,6 +4427,13 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/types" "^7.3.0"
|
"@babel/types" "^7.3.0"
|
||||||
|
|
||||||
|
"@types/bcrypt@^5.0.2":
|
||||||
|
version "5.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/bcrypt/-/bcrypt-5.0.2.tgz#22fddc11945ea4fbc3655b3e8b8847cc9f811477"
|
||||||
|
integrity sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" "*"
|
||||||
|
|
||||||
"@types/body-parser@*":
|
"@types/body-parser@*":
|
||||||
version "1.19.2"
|
version "1.19.2"
|
||||||
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
|
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
|
||||||
|
@ -4447,6 +4474,11 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
|
|
||||||
|
"@types/cookie@0.6.0":
|
||||||
|
version "0.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5"
|
||||||
|
integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==
|
||||||
|
|
||||||
"@types/cors@^2.8.12":
|
"@types/cors@^2.8.12":
|
||||||
version "2.8.12"
|
version "2.8.12"
|
||||||
resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080"
|
resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080"
|
||||||
|
@ -5857,6 +5889,11 @@ abab@^2.0.6:
|
||||||
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291"
|
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291"
|
||||||
integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==
|
integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==
|
||||||
|
|
||||||
|
abbrev@1:
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
|
||||||
|
integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
|
||||||
|
|
||||||
accepts@~1.3.4:
|
accepts@~1.3.4:
|
||||||
version "1.3.7"
|
version "1.3.7"
|
||||||
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
|
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
|
||||||
|
@ -6841,6 +6878,14 @@ bcrypt-pbkdf@^1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
tweetnacl "^0.14.3"
|
tweetnacl "^0.14.3"
|
||||||
|
|
||||||
|
bcrypt@^5.1.1:
|
||||||
|
version "5.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.1.1.tgz#0f732c6dcb4e12e5b70a25e326a72965879ba6e2"
|
||||||
|
integrity sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==
|
||||||
|
dependencies:
|
||||||
|
"@mapbox/node-pre-gyp" "^1.0.11"
|
||||||
|
node-addon-api "^5.0.0"
|
||||||
|
|
||||||
bcryptjs@^2.3.0:
|
bcryptjs@^2.3.0:
|
||||||
version "2.4.3"
|
version "2.4.3"
|
||||||
resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb"
|
resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb"
|
||||||
|
@ -8045,11 +8090,24 @@ convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer "~5.1.1"
|
safe-buffer "~5.1.1"
|
||||||
|
|
||||||
|
cookie-parser@^1.4.6:
|
||||||
|
version "1.4.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.6.tgz#3ac3a7d35a7a03bbc7e365073a26074824214594"
|
||||||
|
integrity sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==
|
||||||
|
dependencies:
|
||||||
|
cookie "0.4.1"
|
||||||
|
cookie-signature "1.0.6"
|
||||||
|
|
||||||
cookie-signature@1.0.6:
|
cookie-signature@1.0.6:
|
||||||
version "1.0.6"
|
version "1.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
|
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
|
||||||
integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==
|
integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==
|
||||||
|
|
||||||
|
cookie@0.4.1, cookie@^0.4.1:
|
||||||
|
version "0.4.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
|
||||||
|
integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
|
||||||
|
|
||||||
cookie@0.4.2:
|
cookie@0.4.2:
|
||||||
version "0.4.2"
|
version "0.4.2"
|
||||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
|
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
|
||||||
|
@ -8060,10 +8118,10 @@ cookie@0.5.0, cookie@^0.5.0:
|
||||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
|
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
|
||||||
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
|
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
|
||||||
|
|
||||||
cookie@^0.4.1:
|
cookie@0.6.0:
|
||||||
version "0.4.1"
|
version "0.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
|
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
|
||||||
integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
|
integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
|
||||||
|
|
||||||
cookiejar@^2.1.2:
|
cookiejar@^2.1.2:
|
||||||
version "2.1.3"
|
version "2.1.3"
|
||||||
|
@ -8894,6 +8952,11 @@ detab@2.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
repeat-string "^1.5.4"
|
repeat-string "^1.5.4"
|
||||||
|
|
||||||
|
detect-libc@^2.0.0:
|
||||||
|
version "2.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.2.tgz#8ccf2ba9315350e1241b88d0ac3b0e1fbd99605d"
|
||||||
|
integrity sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==
|
||||||
|
|
||||||
detect-newline@^3.0.0:
|
detect-newline@^3.0.0:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651"
|
resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651"
|
||||||
|
@ -13074,16 +13137,21 @@ jose@^2.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
"@panva/asn1.js" "^1.0.0"
|
"@panva/asn1.js" "^1.0.0"
|
||||||
|
|
||||||
jose@^4.10.0, jose@^4.9.2:
|
|
||||||
version "4.11.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/jose/-/jose-4.11.1.tgz#8f7443549befe5bddcf4bae664a9cbc1a62da4fa"
|
|
||||||
integrity sha512-YRv4Tk/Wlug8qicwqFNFVEZSdbROCHRAC6qu/i0dyNKr5JQdoa2pIGoS04lLO/jXQX7Z9omoNewYIVIxqZBd9Q==
|
|
||||||
|
|
||||||
jose@^4.10.3:
|
jose@^4.10.3:
|
||||||
version "4.11.0"
|
version "4.11.0"
|
||||||
resolved "https://registry.yarnpkg.com/jose/-/jose-4.11.0.tgz#1c7f5c7806383d3e836434e8f49da531cb046a9d"
|
resolved "https://registry.yarnpkg.com/jose/-/jose-4.11.0.tgz#1c7f5c7806383d3e836434e8f49da531cb046a9d"
|
||||||
integrity sha512-wLe+lJHeG8Xt6uEubS4x0LVjS/3kXXu9dGoj9BNnlhYq7Kts0Pbb2pvv5KiI0yaKH/eaiR0LUOBhOVo9ktd05A==
|
integrity sha512-wLe+lJHeG8Xt6uEubS4x0LVjS/3kXXu9dGoj9BNnlhYq7Kts0Pbb2pvv5KiI0yaKH/eaiR0LUOBhOVo9ktd05A==
|
||||||
|
|
||||||
|
jose@^4.11.4, jose@^4.15.4:
|
||||||
|
version "4.15.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.4.tgz#02a9a763803e3872cf55f29ecef0dfdcc218cc03"
|
||||||
|
integrity sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==
|
||||||
|
|
||||||
|
jose@^5.1.3:
|
||||||
|
version "5.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/jose/-/jose-5.2.0.tgz#d0ffd7f7e31253f633eefb190a930cd14a916995"
|
||||||
|
integrity sha512-oW3PCnvyrcm1HMvGTzqjxxfnEs9EoFOFWi2HsEGhlFVOXxTE3K9GKWVMFoFw06yPUqwpvEWic1BmtUZBI/tIjw==
|
||||||
|
|
||||||
js-string-escape@^1.0.1:
|
js-string-escape@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef"
|
resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef"
|
||||||
|
@ -14343,6 +14411,11 @@ minipass@^3.0.0, minipass@^3.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
yallist "^4.0.0"
|
yallist "^4.0.0"
|
||||||
|
|
||||||
|
minipass@^5.0.0:
|
||||||
|
version "5.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d"
|
||||||
|
integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==
|
||||||
|
|
||||||
minizlib@^2.1.1:
|
minizlib@^2.1.1:
|
||||||
version "2.1.2"
|
version "2.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
|
resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
|
||||||
|
@ -14570,6 +14643,21 @@ nested-error-stacks@^2.0.0, nested-error-stacks@^2.1.0:
|
||||||
resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.1.1.tgz#26c8a3cee6cc05fbcf1e333cd2fc3e003326c0b5"
|
resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.1.1.tgz#26c8a3cee6cc05fbcf1e333cd2fc3e003326c0b5"
|
||||||
integrity sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==
|
integrity sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==
|
||||||
|
|
||||||
|
next-auth@^4.24.5:
|
||||||
|
version "4.24.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-4.24.5.tgz#1fd1bfc0603c61fd2ba6fd81b976af690edbf07e"
|
||||||
|
integrity sha512-3RafV3XbfIKk6rF6GlLE4/KxjTcuMCifqrmD+98ejFq73SRoj2rmzoca8u764977lH/Q7jo6Xu6yM+Re1Mz/Og==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.20.13"
|
||||||
|
"@panva/hkdf" "^1.0.2"
|
||||||
|
cookie "^0.5.0"
|
||||||
|
jose "^4.11.4"
|
||||||
|
oauth "^0.9.15"
|
||||||
|
openid-client "^5.4.0"
|
||||||
|
preact "^10.6.3"
|
||||||
|
preact-render-to-string "^5.1.19"
|
||||||
|
uuid "^8.3.2"
|
||||||
|
|
||||||
next-tick@^1.0.0, next-tick@^1.1.0:
|
next-tick@^1.0.0, next-tick@^1.1.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb"
|
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb"
|
||||||
|
@ -14633,6 +14721,11 @@ node-addon-api@^3.2.1:
|
||||||
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161"
|
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161"
|
||||||
integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==
|
integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==
|
||||||
|
|
||||||
|
node-addon-api@^5.0.0:
|
||||||
|
version "5.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762"
|
||||||
|
integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==
|
||||||
|
|
||||||
node-dir@^0.1.10:
|
node-dir@^0.1.10:
|
||||||
version "0.1.17"
|
version "0.1.17"
|
||||||
resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5"
|
resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5"
|
||||||
|
@ -14701,6 +14794,13 @@ node-releases@^2.0.2:
|
||||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.2.tgz#7139fe71e2f4f11b47d4d2986aaf8c48699e0c01"
|
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.2.tgz#7139fe71e2f4f11b47d4d2986aaf8c48699e0c01"
|
||||||
integrity sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==
|
integrity sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==
|
||||||
|
|
||||||
|
nopt@^5.0.0:
|
||||||
|
version "5.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88"
|
||||||
|
integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==
|
||||||
|
dependencies:
|
||||||
|
abbrev "1"
|
||||||
|
|
||||||
normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.5.0:
|
normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.5.0:
|
||||||
version "2.5.0"
|
version "2.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
|
resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
|
||||||
|
@ -14815,6 +14915,16 @@ nx@15.5.2:
|
||||||
yargs "^17.6.2"
|
yargs "^17.6.2"
|
||||||
yargs-parser "21.1.1"
|
yargs-parser "21.1.1"
|
||||||
|
|
||||||
|
oauth4webapi@^2.4.0:
|
||||||
|
version "2.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/oauth4webapi/-/oauth4webapi-2.6.0.tgz#776a2eb5ca6ad5e5249c4bb6194516318406d254"
|
||||||
|
integrity sha512-4P43og0d8fQ61RMQEl9L7zwGVduuYbLED7uP99MkFSGuOUvJL1Fs52/D3tRtKoFtiSwKblScTYJI+utQn3SUDg==
|
||||||
|
|
||||||
|
oauth@^0.9.15:
|
||||||
|
version "0.9.15"
|
||||||
|
resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1"
|
||||||
|
integrity sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==
|
||||||
|
|
||||||
object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.1, object-assign@latest:
|
object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.1, object-assign@latest:
|
||||||
version "4.1.1"
|
version "4.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
||||||
|
@ -14829,7 +14939,7 @@ object-copy@^0.1.0:
|
||||||
define-property "^0.2.5"
|
define-property "^0.2.5"
|
||||||
kind-of "^3.0.3"
|
kind-of "^3.0.3"
|
||||||
|
|
||||||
object-hash@^2.0.1:
|
object-hash@^2.0.1, object-hash@^2.2.0:
|
||||||
version "2.2.0"
|
version "2.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5"
|
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5"
|
||||||
integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==
|
integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==
|
||||||
|
@ -14988,6 +15098,11 @@ oidc-token-hash@^5.0.1:
|
||||||
resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz#ae6beec3ec20f0fd885e5400d175191d6e2f10c6"
|
resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz#ae6beec3ec20f0fd885e5400d175191d6e2f10c6"
|
||||||
integrity sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ==
|
integrity sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ==
|
||||||
|
|
||||||
|
oidc-token-hash@^5.0.3:
|
||||||
|
version "5.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz#9a229f0a1ce9d4fc89bcaee5478c97a889e7b7b6"
|
||||||
|
integrity sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==
|
||||||
|
|
||||||
on-finished@2.4.1:
|
on-finished@2.4.1:
|
||||||
version "2.4.1"
|
version "2.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
|
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
|
||||||
|
@ -15063,15 +15178,15 @@ openid-client@^4.9.1:
|
||||||
object-hash "^2.0.1"
|
object-hash "^2.0.1"
|
||||||
oidc-token-hash "^5.0.1"
|
oidc-token-hash "^5.0.1"
|
||||||
|
|
||||||
openid-client@^5.2.1:
|
openid-client@^5.4.0:
|
||||||
version "5.3.1"
|
version "5.6.4"
|
||||||
resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.3.1.tgz#69a5fa7d2b5ad479032f576852d40b4d4435488a"
|
resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.6.4.tgz#b2c25e6d5338ba3ce00e04341bb286798a196177"
|
||||||
integrity sha512-RLfehQiHch9N6tRWNx68cicf3b1WR0x74bJWHRc25uYIbSRwjxYcTFaRnzbbpls5jroLAaB/bFIodTgA5LJMvw==
|
integrity sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA==
|
||||||
dependencies:
|
dependencies:
|
||||||
jose "^4.10.0"
|
jose "^4.15.4"
|
||||||
lru-cache "^6.0.0"
|
lru-cache "^6.0.0"
|
||||||
object-hash "^2.0.1"
|
object-hash "^2.2.0"
|
||||||
oidc-token-hash "^5.0.1"
|
oidc-token-hash "^5.0.3"
|
||||||
|
|
||||||
opn@latest:
|
opn@latest:
|
||||||
version "6.0.0"
|
version "6.0.0"
|
||||||
|
@ -16143,11 +16258,30 @@ postmark@^3.0.14:
|
||||||
dependencies:
|
dependencies:
|
||||||
axios "^0.25.0"
|
axios "^0.25.0"
|
||||||
|
|
||||||
preact@^10.5.13:
|
preact-render-to-string@5.2.3:
|
||||||
|
version "5.2.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz#23d17376182af720b1060d5a4099843c7fe92fe4"
|
||||||
|
integrity sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==
|
||||||
|
dependencies:
|
||||||
|
pretty-format "^3.8.0"
|
||||||
|
|
||||||
|
preact-render-to-string@^5.1.19:
|
||||||
|
version "5.2.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz#0ff0c86cd118d30affb825193f18e92bd59d0604"
|
||||||
|
integrity sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==
|
||||||
|
dependencies:
|
||||||
|
pretty-format "^3.8.0"
|
||||||
|
|
||||||
|
preact@10.11.3, preact@^10.5.13:
|
||||||
version "10.11.3"
|
version "10.11.3"
|
||||||
resolved "https://registry.yarnpkg.com/preact/-/preact-10.11.3.tgz#8a7e4ba19d3992c488b0785afcc0f8aa13c78d19"
|
resolved "https://registry.yarnpkg.com/preact/-/preact-10.11.3.tgz#8a7e4ba19d3992c488b0785afcc0f8aa13c78d19"
|
||||||
integrity sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==
|
integrity sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==
|
||||||
|
|
||||||
|
preact@^10.6.3:
|
||||||
|
version "10.19.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/preact/-/preact-10.19.3.tgz#7a7107ed2598a60676c943709ea3efb8aaafa899"
|
||||||
|
integrity sha512-nHHTeFVBTHRGxJXKkKu5hT8C/YWBkPso4/Gad6xuj5dbptt9iF9NZr9pHbPhBrnT2klheu7mHTxTZ/LjwJiEiQ==
|
||||||
|
|
||||||
prelude-ls@^1.2.1:
|
prelude-ls@^1.2.1:
|
||||||
version "1.2.1"
|
version "1.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
|
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
|
||||||
|
@ -16217,6 +16351,11 @@ pretty-format@^28.1.1, pretty-format@^28.1.3:
|
||||||
ansi-styles "^5.0.0"
|
ansi-styles "^5.0.0"
|
||||||
react-is "^18.0.0"
|
react-is "^18.0.0"
|
||||||
|
|
||||||
|
pretty-format@^3.8.0:
|
||||||
|
version "3.8.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-3.8.0.tgz#bfbed56d5e9a776645f4b1ff7aa1a3ac4fa3c385"
|
||||||
|
integrity sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==
|
||||||
|
|
||||||
pretty-hrtime@^1.0.3:
|
pretty-hrtime@^1.0.3:
|
||||||
version "1.0.3"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
|
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
|
||||||
|
@ -16972,6 +17111,11 @@ regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.7:
|
||||||
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
|
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
|
||||||
integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
|
integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
|
||||||
|
|
||||||
|
regenerator-runtime@^0.14.0:
|
||||||
|
version "0.14.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f"
|
||||||
|
integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==
|
||||||
|
|
||||||
regenerator-transform@^0.15.0:
|
regenerator-transform@^0.15.0:
|
||||||
version "0.15.0"
|
version "0.15.0"
|
||||||
resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.0.tgz#cbd9ead5d77fae1a48d957cf889ad0586adb6537"
|
resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.0.tgz#cbd9ead5d77fae1a48d957cf889ad0586adb6537"
|
||||||
|
@ -18773,6 +18917,18 @@ tar@^6.0.2:
|
||||||
mkdirp "^1.0.3"
|
mkdirp "^1.0.3"
|
||||||
yallist "^4.0.0"
|
yallist "^4.0.0"
|
||||||
|
|
||||||
|
tar@^6.1.11:
|
||||||
|
version "6.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.0.tgz#b14ce49a79cb1cd23bc9b016302dea5474493f73"
|
||||||
|
integrity sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==
|
||||||
|
dependencies:
|
||||||
|
chownr "^2.0.0"
|
||||||
|
fs-minipass "^2.0.0"
|
||||||
|
minipass "^5.0.0"
|
||||||
|
minizlib "^2.1.1"
|
||||||
|
mkdirp "^1.0.3"
|
||||||
|
yallist "^4.0.0"
|
||||||
|
|
||||||
telejson@^6.0.8:
|
telejson@^6.0.8:
|
||||||
version "6.0.8"
|
version "6.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/telejson/-/telejson-6.0.8.tgz#1c432db7e7a9212c1fbd941c3e5174ec385148f7"
|
resolved "https://registry.yarnpkg.com/telejson/-/telejson-6.0.8.tgz#1c432db7e7a9212c1fbd941c3e5174ec385148f7"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue