mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 23:45:21 +02:00
remove most of the Auth0 useage
This commit is contained in:
parent
7267c30fc9
commit
6100ab14c6
37 changed files with 40 additions and 1283 deletions
|
@ -8,14 +8,13 @@ NX_POLYGON_API_KEY=
|
|||
# If using free ngrok account for webhooks
|
||||
NGROK_AUTH_TOKEN=
|
||||
|
||||
# Required for Auth0 deploy client (see `yarn auth0:deploy` command)
|
||||
AUTH0_ENV=development
|
||||
NX_AUTH0_MGMT_CLIENT_SECRET=
|
||||
NX_AUTH0_CLIENT_SECRET=
|
||||
AUTH0_DEPLOY_CLIENT_SECRET=
|
||||
POSTMARK_SMTP_PASS=
|
||||
NX_SESSION_SECRET=
|
||||
|
||||
# Generate a new secret using openssl rand -base64 32
|
||||
NEXTAUTH_SECRET=
|
||||
NEXTAUTH_URL=http://localhost:4200
|
||||
NX_NEXTAUTH_URL=http://localhost:4200
|
||||
|
||||
NX_PLAID_SECRET=
|
||||
NX_FINICITY_APP_KEY=
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
import {
|
||||
type ModalKey,
|
||||
type ModalManagerAction,
|
||||
ModalManagerContext,
|
||||
} from '@maybe-finance/client/shared'
|
||||
import { type PropsWithChildren, useReducer } from 'react'
|
||||
|
||||
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 [, dispatch] = useReducer(reducer, {
|
||||
linkAuth0Accounts: { isOpen: false, props: null },
|
||||
})
|
||||
|
||||
return (
|
||||
<ModalManagerContext.Provider value={{ dispatch }}>{children}</ModalManagerContext.Provider>
|
||||
)
|
||||
}
|
|
@ -16,7 +16,6 @@ import { BrowserTracing } from '@sentry/tracing'
|
|||
import env from '../env'
|
||||
import '../styles.css'
|
||||
import { SessionProvider, useSession } from 'next-auth/react'
|
||||
import ModalManager from '../components/ModalManager'
|
||||
import Meta from '../components/Meta'
|
||||
import APM from '../components/APM'
|
||||
import { useRouter } from 'next/router'
|
||||
|
@ -46,14 +45,12 @@ const WithAuth = function ({ children }: PropsWithChildren) {
|
|||
if (session) {
|
||||
return (
|
||||
<OnboardingGuard>
|
||||
<ModalManager>
|
||||
<UserAccountContextProvider>
|
||||
<AccountContextProvider>
|
||||
{children}
|
||||
<AccountsManager />
|
||||
</AccountContextProvider>
|
||||
</UserAccountContextProvider>
|
||||
</ModalManager>
|
||||
<UserAccountContextProvider>
|
||||
<AccountContextProvider>
|
||||
{children}
|
||||
<AccountsManager />
|
||||
</AccountContextProvider>
|
||||
</UserAccountContextProvider>
|
||||
</OnboardingGuard>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@ import { createLogger, transports } from 'winston'
|
|||
import { DateTime } from 'luxon'
|
||||
import { PgService } from '@maybe-finance/server/shared'
|
||||
import { AccountQueryService, UserService } from '@maybe-finance/server/features'
|
||||
import { managementClient } from '../lib/auth0'
|
||||
import { resetUser } from './utils/user'
|
||||
jest.mock('plaid')
|
||||
jest.mock('auth0')
|
||||
|
@ -38,7 +37,6 @@ describe('user net worth', () => {
|
|||
},
|
||||
{} as any,
|
||||
{} as any,
|
||||
managementClient,
|
||||
{} as any
|
||||
)
|
||||
|
||||
|
|
|
@ -3,7 +3,6 @@ import { PrismaClient } from '@prisma/client'
|
|||
import { createLogger, transports } from 'winston'
|
||||
import { AccountQueryService, UserService } from '@maybe-finance/server/features'
|
||||
import { resetUser } from './utils/user'
|
||||
import { managementClient } from '../lib/auth0'
|
||||
import stripe from '../lib/stripe'
|
||||
import { PgService } from '@maybe-finance/server/shared'
|
||||
import { DateTime } from 'luxon'
|
||||
|
@ -18,7 +17,6 @@ const userService = new UserService(
|
|||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
managementClient,
|
||||
stripe
|
||||
)
|
||||
|
||||
|
|
|
@ -23,10 +23,7 @@ export async function getAxiosClient() {
|
|||
password: 'REPLACE_THIS',
|
||||
audience: 'https://maybe-finance-api/v1',
|
||||
scope: '',
|
||||
client_id: isCI
|
||||
? 'REPLACE_THIS'
|
||||
: 'REPLACE_THIS',
|
||||
client_secret: env.NX_AUTH0_CLIENT_SECRET,
|
||||
client_id: isCI ? 'REPLACE_THIS' : 'REPLACE_THIS',
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
@ -30,7 +30,6 @@ import {
|
|||
usersRouter,
|
||||
accountsRouter,
|
||||
connectionsRouter,
|
||||
adminRouter,
|
||||
webhooksRouter,
|
||||
plaidRouter,
|
||||
accountRollupRouter,
|
||||
|
@ -92,7 +91,6 @@ app.use(cors({ origin, credentials: true }))
|
|||
app.options('*', cors() as RequestHandler)
|
||||
|
||||
app.set('view engine', 'ejs').set('views', __dirname + '/app/admin/views')
|
||||
app.use('/admin', adminRouter)
|
||||
|
||||
app.use(
|
||||
morgan(env.NX_MORGAN_LOG_LEVEL, {
|
||||
|
|
|
@ -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',
|
||||
})
|
|
@ -57,7 +57,6 @@ import plaid, { getPlaidWebhookUrl } from './plaid'
|
|||
import finicity, { getFinicityTxPushUrl, getFinicityWebhookUrl } from './finicity'
|
||||
import stripe from './stripe'
|
||||
import postmark from './postmark'
|
||||
import { managementClient } from './auth0'
|
||||
import defineAbilityFor from './ability'
|
||||
import env from '../../env'
|
||||
import logger from '../lib/logger'
|
||||
|
@ -219,7 +218,6 @@ const userService = new UserService(
|
|||
balanceSyncStrategyFactory,
|
||||
queueService.getQueue('sync-user'),
|
||||
queueService.getQueue('purge-user'),
|
||||
managementClient,
|
||||
stripe
|
||||
)
|
||||
|
||||
|
@ -318,7 +316,6 @@ export async function createContext(req: Request) {
|
|||
prisma,
|
||||
plaid,
|
||||
stripe,
|
||||
managementClient,
|
||||
logger,
|
||||
user,
|
||||
ability: defineAbilityFor(user),
|
||||
|
|
|
@ -3,7 +3,7 @@ import * as Sentry from '@sentry/node'
|
|||
|
||||
export const identifySentryUser: ErrorRequestHandler = (err, req, _res, next) => {
|
||||
Sentry.setUser({
|
||||
auth0Id: req.user?.sub,
|
||||
authId: req.user?.sub,
|
||||
})
|
||||
|
||||
next(err)
|
||||
|
|
|
@ -2,7 +2,6 @@ export * from './dev-only'
|
|||
export * from './error-handler'
|
||||
export * from './auth-error-handler'
|
||||
export * from './superjson'
|
||||
export * from './validate-auth0-jwt'
|
||||
export * from './validate-auth-jwt'
|
||||
export * from './validate-plaid-jwt'
|
||||
export * from './validate-finicity-signature'
|
||||
|
|
|
@ -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
|
|
@ -5,7 +5,6 @@ export { default as usersRouter } from './users.router'
|
|||
export { default as webhooksRouter } from './webhooks.router'
|
||||
export { default as plaidRouter } from './plaid.router'
|
||||
export { default as finicityRouter } from './finicity.router'
|
||||
export { default as adminRouter } from './admin.router'
|
||||
export { default as valuationsRouter } from './valuations.router'
|
||||
export { default as institutionsRouter } from './institutions.router'
|
||||
export { default as transactionsRouter } from './transactions.router'
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import { Router } from 'express'
|
||||
import axios from 'axios'
|
||||
import type { UnlinkAccountsParamsProvider } from 'auth0'
|
||||
import { subject } from '@casl/ability'
|
||||
import { z } from 'zod'
|
||||
import { DateUtil, type SharedType } from '@maybe-finance/shared'
|
||||
|
@ -114,31 +112,6 @@ router.get(
|
|||
})
|
||||
)
|
||||
|
||||
// TODO: Remove this endpoint
|
||||
router.get(
|
||||
'/auth0-profile',
|
||||
endpoint.create({
|
||||
resolve: async ({ ctx }) => {
|
||||
return ctx.userService.getAuth0Profile(ctx.user!)
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
router.put(
|
||||
'/auth0-profile',
|
||||
endpoint.create({
|
||||
input: z.object({
|
||||
enrolled_mfa: z.boolean(),
|
||||
}),
|
||||
resolve: ({ input, ctx }) => {
|
||||
return ctx.managementClient.updateUser(
|
||||
{ id: ctx.user!.authId }, // TODO: Remove this endpoint
|
||||
{ user_metadata: { enrolled_mfa: input.enrolled_mfa } }
|
||||
)
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/subscription',
|
||||
endpoint.create({
|
||||
|
@ -286,43 +259,7 @@ router.get(
|
|||
})
|
||||
)
|
||||
|
||||
// TODO: Remove this endpoint or refactor to work with new Auth
|
||||
router.post(
|
||||
'/link-accounts',
|
||||
endpoint.create({
|
||||
input: z.object({
|
||||
secondaryJWT: z.string(),
|
||||
secondaryProvider: z.string(),
|
||||
}),
|
||||
resolve: async ({ input, ctx }) => {
|
||||
return ctx.userService.linkAccounts(ctx.user!.authId, input.secondaryProvider, {
|
||||
token: input.secondaryJWT,
|
||||
domain: env.NX_AUTH0_CUSTOM_DOMAIN,
|
||||
audience: env.NX_AUTH0_AUDIENCE,
|
||||
})
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// TODO: Remove this endpoint or refactor to work with new Auth
|
||||
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!.authId,
|
||||
input.secondaryAuth0Id,
|
||||
input.secondaryProvider as UnlinkAccountsParamsProvider
|
||||
)
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// TODO: Refactor this to use the Auth Id instead of Auth0
|
||||
// TODO: Implement verification email using Postmark instead of Auth0
|
||||
router.post(
|
||||
'/resend-verification-email',
|
||||
endpoint.create({
|
||||
|
@ -333,7 +270,7 @@ router.post(
|
|||
const authId = input.authId ?? ctx.user?.authId
|
||||
if (!authId) throw new Error('User not found')
|
||||
|
||||
await ctx.managementClient.sendEmailVerification({ user_id: authId })
|
||||
//await ctx.managementClient.sendEmailVerification({ user_id: authId })
|
||||
|
||||
ctx.logger.info(`Sent verification email to ${authId}`)
|
||||
|
||||
|
@ -354,52 +291,18 @@ router.put(
|
|||
throw new Error('Unable to update password. No user found.')
|
||||
}
|
||||
|
||||
const user = await ctx.managementClient.getUser({ id: req.user.sub })
|
||||
|
||||
const { newPassword, currentPassword } = input
|
||||
|
||||
/**
|
||||
* Auth0 doesn't have a verify password endpoint on the Management API, so this is a secure way to
|
||||
* verify that the old password was valid before changing it. Why they don't have this feature still? ¯\_(ツ)_/¯
|
||||
*
|
||||
* @see https://community.auth0.com/t/change-password-validation/8158/10
|
||||
*/
|
||||
try {
|
||||
// If this succeeds, we know the old password was correct
|
||||
await axios.post(
|
||||
`https://${env.NX_AUTH0_DOMAIN}/oauth/token`,
|
||||
{
|
||||
grant_type: 'password',
|
||||
username: user.email,
|
||||
password: currentPassword,
|
||||
audience: env.NX_AUTH0_AUDIENCE,
|
||||
client_id: env.NX_AUTH0_CLIENT_ID,
|
||||
client_secret: env.NX_AUTH0_CLIENT_SECRET,
|
||||
},
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
await ctx.authUserService.updatePassword(req.user.sub, currentPassword, newPassword)
|
||||
} catch (err) {
|
||||
let errMessage = 'Could not reset password'
|
||||
|
||||
if (axios.isAxiosError(err)) {
|
||||
errMessage =
|
||||
err.response?.status === 401
|
||||
? 'Invalid password, please try again'
|
||||
: errMessage
|
||||
}
|
||||
|
||||
const errMessage = 'Could not reset password'
|
||||
// Do not log the full error here, the user's password could be in it!
|
||||
ctx.logger.error('Could not reset password')
|
||||
|
||||
return { success: false, error: errMessage }
|
||||
}
|
||||
|
||||
// https://auth0.com/docs/connections/database/password-change#use-the-management-api
|
||||
await ctx.managementClient.updateUser(
|
||||
{ id: req.user?.sub },
|
||||
{ password: newPassword, connection: 'Username-Password-Authentication' }
|
||||
)
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
})
|
||||
|
@ -439,9 +342,8 @@ router.post(
|
|||
customer: ctx.user.stripeCustomerId,
|
||||
}
|
||||
: {
|
||||
customer_email: (
|
||||
await ctx.managementClient.getUser({ id: req.user.sub })
|
||||
).email,
|
||||
customer_email:
|
||||
(await ctx.authUserService.get(req.user.sub)).email ?? undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
|
@ -33,15 +33,6 @@ const envSchema = z.object({
|
|||
|
||||
NX_NGROK_URL: z.string().default('http://localhost:4551'),
|
||||
|
||||
// Dev doesn't have a custom domain, so replace with the original dev URL
|
||||
NX_AUTH0_DOMAIN: z.string().default('REPLACE_THIS'),
|
||||
NX_AUTH0_CUSTOM_DOMAIN: z.string().default('REPLACE_THIS'),
|
||||
NX_AUTH0_AUDIENCE: z.string().default('https://maybe-finance-api/v1'),
|
||||
NX_AUTH0_CLIENT_ID: z.string().default('REPLACE_THIS'),
|
||||
NX_AUTH0_CLIENT_SECRET: z.string(),
|
||||
NX_AUTH0_MGMT_CLIENT_ID: z.string().default('REPLACE_THIS'),
|
||||
NX_AUTH0_MGMT_CLIENT_SECRET: z.string(),
|
||||
|
||||
NX_PLAID_CLIENT_ID: z.string().default('REPLACE_THIS'),
|
||||
NX_PLAID_SECRET: z.string(),
|
||||
NX_PLAID_ENV: z.string().default('sandbox'),
|
||||
|
|
|
@ -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,
|
||||
PlaidETL,
|
||||
PlaidService,
|
||||
PropertyService,
|
||||
SecurityPricingProcessor,
|
||||
SecurityPricingService,
|
||||
TransactionBalanceSyncStrategy,
|
||||
UserProcessor,
|
||||
UserService,
|
||||
ValuationBalanceSyncStrategy,
|
||||
VehicleService,
|
||||
EmailService,
|
||||
EmailProcessor,
|
||||
TransactionService,
|
||||
|
@ -58,7 +56,6 @@ import prisma from './prisma'
|
|||
import plaid from './plaid'
|
||||
import finicity from './finicity'
|
||||
import postmark from './postmark'
|
||||
import { managementClient } from './auth0'
|
||||
import stripe from './stripe'
|
||||
import env from '../../env'
|
||||
import { BullQueueEventHandler, WorkerErrorHandlerService } from '../services'
|
||||
|
@ -127,10 +124,6 @@ const finicityService = new FinicityService(
|
|||
env.NX_FINICITY_ENV === 'sandbox'
|
||||
)
|
||||
|
||||
const propertyService = new PropertyService(logger.child({ service: 'PropertyService' }))
|
||||
|
||||
const vehicleService = new VehicleService(logger.child({ service: 'VehicleService' }))
|
||||
|
||||
// account-connection
|
||||
|
||||
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
|
||||
|
@ -228,7 +221,6 @@ export const userService: IUserService = new UserService(
|
|||
balanceSyncStrategyFactory,
|
||||
queueService.getQueue('sync-user'),
|
||||
queueService.getQueue('purge-user'),
|
||||
managementClient,
|
||||
stripe
|
||||
)
|
||||
|
||||
|
@ -282,6 +274,5 @@ export const emailService: IEmailService = new EmailService(
|
|||
export const emailProcessor: IEmailProcessor = new EmailProcessor(
|
||||
logger.child({ service: 'EmailProcessor' }),
|
||||
prisma,
|
||||
managementClient,
|
||||
emailService
|
||||
)
|
||||
|
|
|
@ -24,25 +24,13 @@ const envSchema = z.object({
|
|||
|
||||
NX_POLYGON_API_KEY: z.string().default(''),
|
||||
|
||||
NX_AUTH0_DOMAIN: z.string().default('REPLACE_THIS'),
|
||||
NX_AUTH0_MGMT_CLIENT_ID: z.string().default('REPLACE_THIS'),
|
||||
NX_AUTH0_MGMT_CLIENT_SECRET: z.string(),
|
||||
|
||||
NX_POSTMARK_FROM_ADDRESS: z.string().default('account@maybe.co'),
|
||||
NX_POSTMARK_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'),
|
||||
NX_POSTMARK_API_TOKEN: z.string().default('REPLACE_THIS'),
|
||||
NX_STRIPE_SECRET_KEY: z
|
||||
.string()
|
||||
.default(
|
||||
'sk_test_REPLACE_THIS'
|
||||
),
|
||||
NX_STRIPE_SECRET_KEY: z.string().default('sk_test_REPLACE_THIS'),
|
||||
|
||||
NX_CDN_PRIVATE_BUCKET: z
|
||||
.string()
|
||||
.default('REPLACE_THIS'),
|
||||
NX_CDN_PUBLIC_BUCKET: z
|
||||
.string()
|
||||
.default('REPLACE_THIS'),
|
||||
NX_CDN_PRIVATE_BUCKET: z.string().default('REPLACE_THIS'),
|
||||
NX_CDN_PUBLIC_BUCKET: z.string().default('REPLACE_THIS'),
|
||||
})
|
||||
|
||||
const env = envSchema.parse(process.env)
|
||||
|
|
|
@ -4,13 +4,11 @@ export * from './holdings-list'
|
|||
export * from './insights'
|
||||
export * from './user-billing'
|
||||
export * from './user-details'
|
||||
export * from './user-notifications'
|
||||
export * from './user-security'
|
||||
export * from './transactions-list'
|
||||
export * from './investment-transactions-list'
|
||||
export * from './layout'
|
||||
export * from './accounts-manager'
|
||||
export * from './user'
|
||||
export * from './net-worth-insights'
|
||||
export * from './data-editor'
|
||||
export * from './loan-details'
|
||||
|
|
|
@ -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,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 './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 { Button, LoadingSpinner } from '@maybe-finance/design-system'
|
||||
import { MultiFactorAuthentication } from './MultiFactorAuthentication'
|
||||
import { PasswordReset } from './PasswordReset'
|
||||
|
||||
export function SecurityPreferences() {
|
||||
const { useAuth0Profile } = useUserApi()
|
||||
const profileQuery = useAuth0Profile()
|
||||
|
||||
if (profileQuery.isLoading) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
||||
if (profileQuery.isError) {
|
||||
return (
|
||||
<p className="text-gray-50">
|
||||
Something went wrong loading your security preferences...
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const { socialOnlyUser, mfaEnabled } = profileQuery.data
|
||||
|
||||
return socialOnlyUser ? (
|
||||
<>
|
||||
<p className="text-base text-white">
|
||||
Your account credentials are managed by Apple. To reset your password, click the
|
||||
button below to go to your Apple settings.
|
||||
</p>
|
||||
<Button className="mt-4" href="https://appleid.apple.com/">
|
||||
Manage Apple Account
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
return (
|
||||
<>
|
||||
<h4 className="mb-2 text-lg uppercase">Password</h4>
|
||||
<PasswordReset />
|
||||
|
||||
<h4 className="mb-2 mt-8 text-lg uppercase">Multi-Factor Authentication</h4>
|
||||
<p className="text-base text-gray-100">
|
||||
Add an extra layer of security by setting up multi-factor authentication. This will
|
||||
need an app like Google Authenticator or Authy.
|
||||
</p>
|
||||
<MultiFactorAuthentication enabled={mfaEnabled} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,20 +1,14 @@
|
|||
import type { UseMutationOptions, UseQueryOptions } from '@tanstack/react-query'
|
||||
import type { SharedType } from '@maybe-finance/shared'
|
||||
import type { Auth0ContextInterface } from '@auth0/auth0-react'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
import Axios from 'axios'
|
||||
import * as Sentry from '@sentry/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { DateTime } from 'luxon'
|
||||
import { useAxiosWithAuth } from '../hooks/useAxiosWithAuth'
|
||||
import { useAuth0 } from '@auth0/auth0-react'
|
||||
|
||||
const UserApi = (
|
||||
axios: AxiosInstance,
|
||||
auth0: Auth0ContextInterface<SharedType.Auth0ReactUser>
|
||||
) => ({
|
||||
const UserApi = (axios: AxiosInstance) => ({
|
||||
async getNetWorthSeries(start: string, end: string) {
|
||||
const { data } = await axios.get<SharedType.NetWorthTimeSeriesResponse>(
|
||||
`/users/net-worth`,
|
||||
|
@ -70,67 +64,11 @@ const UserApi = (
|
|||
return data
|
||||
},
|
||||
|
||||
async getAuth0Profile() {
|
||||
const { data } = await axios.get<SharedType.Auth0Profile>('/users/auth0-profile')
|
||||
return data
|
||||
},
|
||||
|
||||
async updateAuth0Profile(newProfile: SharedType.UpdateAuth0User) {
|
||||
const { data } = await axios.put<
|
||||
SharedType.Auth0User,
|
||||
SharedType.ApiResponse<SharedType.Auth0User>
|
||||
>('/users/auth0-profile', newProfile)
|
||||
return data
|
||||
},
|
||||
|
||||
async getSubscription() {
|
||||
const { data } = await axios.get<SharedType.UserSubscription>('/users/subscription')
|
||||
return data
|
||||
},
|
||||
|
||||
async toggleMFA(desiredMFAState: 'enabled' | 'disabled'): Promise<{
|
||||
actualMFAState: 'enabled' | 'disabled'
|
||||
desiredMFAState: 'enabled' | 'disabled'
|
||||
mfaRegistrationComplete: boolean
|
||||
}> {
|
||||
const audience = process.env.NEXT_PUBLIC_AUTH0_AUDIENCE || 'https://maybe-finance-api/v1'
|
||||
|
||||
await axios.put<SharedType.Auth0User, SharedType.ApiResponse<SharedType.Auth0User>>(
|
||||
'/users/auth0-profile',
|
||||
{
|
||||
user_metadata: { enrolled_mfa: desiredMFAState === 'enabled' ? true : false },
|
||||
}
|
||||
)
|
||||
|
||||
// If the user is enabling MFA, prompt them to set it up immediately
|
||||
if (desiredMFAState === 'enabled') {
|
||||
await auth0.loginWithPopup(
|
||||
{
|
||||
authorizationParams: {
|
||||
connection: 'Username-Password-Authentication',
|
||||
screen_hint: 'show-form-only',
|
||||
display: 'page',
|
||||
audience,
|
||||
},
|
||||
},
|
||||
{ timeoutInSeconds: 360 }
|
||||
)
|
||||
}
|
||||
|
||||
const currentIdTokenMFAState = auth0.user?.['https://maybe.co/user-metadata']?.enrolled_mfa
|
||||
? 'enabled'
|
||||
: 'disabled'
|
||||
|
||||
// If the ID token is the same as the user's intended MFA state, that means they successfully
|
||||
// completed the flow. If not, they closed the popup early.
|
||||
return {
|
||||
actualMFAState: currentIdTokenMFAState,
|
||||
desiredMFAState,
|
||||
mfaRegistrationComplete:
|
||||
currentIdTokenMFAState === desiredMFAState || desiredMFAState === 'disabled',
|
||||
}
|
||||
},
|
||||
|
||||
async changePassword(newPassword: SharedType.PasswordReset) {
|
||||
const { data } = await axios.put<
|
||||
SharedType.PasswordReset,
|
||||
|
@ -139,32 +77,6 @@ const UserApi = (
|
|||
return data
|
||||
},
|
||||
|
||||
async linkAccounts({ secondaryJWT, secondaryProvider }: SharedType.LinkAccounts) {
|
||||
try {
|
||||
const { data } = await axios.post<
|
||||
SharedType.LinkAccounts,
|
||||
SharedType.ApiResponse<SharedType.Auth0User>
|
||||
>('/users/link-accounts', { secondaryJWT, secondaryProvider })
|
||||
return data
|
||||
} catch (err) {
|
||||
if (Axios.isAxiosError(err)) {
|
||||
const message = err.response?.data?.errors?.[0]?.title
|
||||
throw new Error(message ?? 'Something went wrong')
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
async unlinkAccount(unlinkData: SharedType.UnlinkAccount) {
|
||||
const { data } = await axios.post<
|
||||
SharedType.UnlinkAccount,
|
||||
SharedType.ApiResponse<SharedType.Auth0User>
|
||||
>('/users/unlink-account', unlinkData)
|
||||
|
||||
return data
|
||||
},
|
||||
|
||||
async resendEmailVerification(authId?: string) {
|
||||
const { data } = await axios.post<{ success: boolean }>(
|
||||
'/users/resend-verification-email',
|
||||
|
@ -206,8 +118,7 @@ const staleTimes = {
|
|||
export function useUserApi() {
|
||||
const queryClient = useQueryClient()
|
||||
const { axios } = useAxiosWithAuth()
|
||||
const auth0 = useAuth0()
|
||||
const api = useMemo(() => UserApi(axios, auth0), [axios, auth0])
|
||||
const api = useMemo(() => UserApi(axios), [axios])
|
||||
|
||||
const useNetWorthSeries = (
|
||||
{ start, end }: { start: string; end: string },
|
||||
|
@ -304,24 +215,6 @@ export function useUserApi() {
|
|||
...options,
|
||||
})
|
||||
|
||||
const useAuth0Profile = (
|
||||
options?: Omit<UseQueryOptions<SharedType.Auth0Profile>, 'queryKey' | 'queryFn'>
|
||||
) => useQuery(['users', 'auth0-profile'], api.getAuth0Profile, options)
|
||||
|
||||
const useUpdateAuth0Profile = (
|
||||
options?: UseMutationOptions<
|
||||
SharedType.Auth0User | undefined,
|
||||
unknown,
|
||||
SharedType.UpdateAuth0User
|
||||
>
|
||||
) =>
|
||||
useMutation(api.updateAuth0Profile, {
|
||||
onSettled() {
|
||||
queryClient.invalidateQueries(['users', 'auth0-profile'])
|
||||
},
|
||||
...options,
|
||||
})
|
||||
|
||||
const useSubscription = (
|
||||
options?: Omit<UseQueryOptions<SharedType.UserSubscription>, 'queryKey' | 'queryFn'>
|
||||
) => useQuery(['users', 'subscription'], api.getSubscription, options)
|
||||
|
@ -339,33 +232,6 @@ export function useUserApi() {
|
|||
},
|
||||
})
|
||||
|
||||
const useLinkAccounts = (
|
||||
options?: UseMutationOptions<
|
||||
SharedType.Auth0User | undefined,
|
||||
unknown,
|
||||
SharedType.LinkAccounts
|
||||
>
|
||||
) => useMutation(api.linkAccounts, options)
|
||||
|
||||
const useUnlinkAccount = (
|
||||
options?: UseMutationOptions<
|
||||
SharedType.Auth0User | undefined,
|
||||
unknown,
|
||||
SharedType.UnlinkAccount
|
||||
>
|
||||
) =>
|
||||
useMutation(api.unlinkAccount, {
|
||||
onSuccess: () => {
|
||||
toast.success('Account unlinked!')
|
||||
queryClient.invalidateQueries(['users'])
|
||||
},
|
||||
onError: (err) => {
|
||||
Sentry.captureException(err)
|
||||
toast.error('Error unlinking user account')
|
||||
},
|
||||
...options,
|
||||
})
|
||||
|
||||
const useResendEmailVerification = (
|
||||
options?: UseMutationOptions<{ success: boolean } | undefined, unknown, string | undefined>
|
||||
) =>
|
||||
|
@ -420,12 +286,8 @@ export function useUserApi() {
|
|||
useProfile,
|
||||
useUpdateProfile,
|
||||
useAuthProfile,
|
||||
useAuth0Profile,
|
||||
useUpdateAuth0Profile,
|
||||
useSubscription,
|
||||
useChangePassword,
|
||||
useLinkAccounts,
|
||||
useUnlinkAccount,
|
||||
useResendEmailVerification,
|
||||
useCreateCheckoutSession,
|
||||
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,3 +1,2 @@
|
|||
export * from './FeedbackDialog'
|
||||
export * from './ConfirmDialog'
|
||||
export * from './NonUSDDialog'
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
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>
|
||||
|
@ -22,6 +23,20 @@ export class AuthUserService implements IAuthUserService {
|
|||
})
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
@ -2,7 +2,6 @@ import type { Logger } from 'winston'
|
|||
import type { PrismaClient } from '@prisma/client'
|
||||
import type { SendEmailQueueJobData } from '@maybe-finance/server/shared'
|
||||
import type { IEmailService } from './email.service'
|
||||
import type { ManagementClient } from 'auth0'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
export interface IEmailProcessor {
|
||||
|
@ -14,7 +13,6 @@ export class EmailProcessor implements IEmailProcessor {
|
|||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly auth0: ManagementClient,
|
||||
private readonly emailService: IEmailService
|
||||
) {}
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import type { AccountCategory, AccountType, PrismaClient, User } from '@prisma/client'
|
||||
import type { Logger } from 'winston'
|
||||
import { AuthUtil, type PurgeUserQueue, type SyncUserQueue } from '@maybe-finance/server/shared'
|
||||
import type { ManagementClient, UnlinkAccountsParamsProvider } from 'auth0'
|
||||
import type { PurgeUserQueue, SyncUserQueue } from '@maybe-finance/server/shared'
|
||||
import type Stripe from 'stripe'
|
||||
import type { IBalanceSyncStrategyFactory } from '../account-balance'
|
||||
import type { IAccountQueryService } from '../account'
|
||||
|
@ -55,7 +54,6 @@ export class UserService implements IUserService {
|
|||
private readonly balanceSyncStrategyFactory: IBalanceSyncStrategyFactory,
|
||||
private readonly syncQueue: SyncUserQueue,
|
||||
private readonly purgeQueue: PurgeUserQueue,
|
||||
private readonly auth0: ManagementClient,
|
||||
private readonly stripe: Stripe
|
||||
) {}
|
||||
|
||||
|
@ -72,50 +70,6 @@ export class UserService implements IUserService {
|
|||
})
|
||||
}
|
||||
|
||||
// TODO: Update this to use new Auth
|
||||
async getAuth0Profile(user: User): Promise<SharedType.Auth0Profile> {
|
||||
if (!user.email) throw new Error('No email found for user')
|
||||
|
||||
const usersWithMatchingEmail = await this.auth0.getUsersByEmail(user.email)
|
||||
const autoPromptEnabled = user.linkAccountDismissedAt == null
|
||||
// TODO: Update this to use new Auth
|
||||
const currentUser = usersWithMatchingEmail.find((u) => u.user_id === user.authId)
|
||||
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.authId &&
|
||||
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']) {
|
||||
const user = await this.get(id)
|
||||
await this.syncQueue.add('sync-user', { userId: user.id })
|
||||
|
@ -554,46 +508,4 @@ export class UserService implements IUserService {
|
|||
|
||||
return onboarding
|
||||
}
|
||||
|
||||
// TODO: Update to work with new Auth
|
||||
async linkAccounts(
|
||||
primaryAuth0Id: User['authId'],
|
||||
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: { authId: validatedJWT.authId } })
|
||||
|
||||
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.authId,
|
||||
provider,
|
||||
})
|
||||
}
|
||||
|
||||
async unlinkAccounts(
|
||||
primaryAuth0Id: User['authId'],
|
||||
secondaryAuth0Id: User['authId'],
|
||||
secondaryProvider: UnlinkAccountsParamsProvider
|
||||
) {
|
||||
const response = await this.auth0.unlinkUsers({
|
||||
id: primaryAuth0Id,
|
||||
provider: secondaryProvider,
|
||||
user_id: secondaryAuth0Id,
|
||||
})
|
||||
|
||||
this.logger.info(`Unlinked ${secondaryAuth0Id} from ${primaryAuth0Id}`)
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import type { User as Auth0UserClient } from '@auth0/auth0-react'
|
||||
import type { Identity, User as Auth0UserServer } from 'auth0'
|
||||
import type {
|
||||
AccountCategory,
|
||||
AccountClassification,
|
||||
|
@ -191,39 +189,11 @@ export type MaybeCustomClaims = {
|
|||
[Auth0CustomNamespace.PrimaryIdentity]?: PrimaryAuth0Identity
|
||||
}
|
||||
|
||||
export type Auth0ReactUser = Auth0UserClient & MaybeCustomClaims
|
||||
export type Auth0User = Auth0UserServer<MaybeAppMetadata, MaybeUserMetadata>
|
||||
export type Auth0Profile = Auth0User & {
|
||||
primaryIdentity: Identity // actual
|
||||
secondaryIdentities: Identity[] // linked
|
||||
suggestedIdentities: Identity[] // potential links
|
||||
autoPromptEnabled: boolean
|
||||
socialOnlyUser: boolean
|
||||
mfaEnabled: boolean
|
||||
}
|
||||
|
||||
export type UpdateAuth0User = { enrolled_mfa: boolean }
|
||||
|
||||
export interface PasswordReset {
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
export type LinkAccountStatus = {
|
||||
autoPromptEnabled: boolean
|
||||
suggestedUsers: Auth0User[]
|
||||
}
|
||||
|
||||
export interface LinkAccounts {
|
||||
secondaryJWT: string
|
||||
secondaryProvider: string
|
||||
}
|
||||
|
||||
export interface UnlinkAccount {
|
||||
secondaryAuth0Id: string
|
||||
secondaryProvider: string
|
||||
}
|
||||
|
||||
export type UserSubscription = {
|
||||
subscribed: boolean
|
||||
trialing: boolean
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue