mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +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
|
# 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_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=
|
||||||
|
|
|
@ -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 env from '../env'
|
||||||
import '../styles.css'
|
import '../styles.css'
|
||||||
import { SessionProvider, useSession } from 'next-auth/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'
|
import { useRouter } from 'next/router'
|
||||||
|
@ -46,14 +45,12 @@ const WithAuth = function ({ children }: PropsWithChildren) {
|
||||||
if (session) {
|
if (session) {
|
||||||
return (
|
return (
|
||||||
<OnboardingGuard>
|
<OnboardingGuard>
|
||||||
<ModalManager>
|
<UserAccountContextProvider>
|
||||||
<UserAccountContextProvider>
|
<AccountContextProvider>
|
||||||
<AccountContextProvider>
|
{children}
|
||||||
{children}
|
<AccountsManager />
|
||||||
<AccountsManager />
|
</AccountContextProvider>
|
||||||
</AccountContextProvider>
|
</UserAccountContextProvider>
|
||||||
</UserAccountContextProvider>
|
|
||||||
</ModalManager>
|
|
||||||
</OnboardingGuard>
|
</OnboardingGuard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,6 @@ import {
|
||||||
usersRouter,
|
usersRouter,
|
||||||
accountsRouter,
|
accountsRouter,
|
||||||
connectionsRouter,
|
connectionsRouter,
|
||||||
adminRouter,
|
|
||||||
webhooksRouter,
|
webhooksRouter,
|
||||||
plaidRouter,
|
plaidRouter,
|
||||||
accountRollupRouter,
|
accountRollupRouter,
|
||||||
|
@ -92,7 +91,6 @@ 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, {
|
||||||
|
|
|
@ -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 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'
|
||||||
|
@ -219,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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -318,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),
|
||||||
|
|
|
@ -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,6 @@ 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-auth-jwt'
|
||||||
export * from './validate-plaid-jwt'
|
export * from './validate-plaid-jwt'
|
||||||
export * from './validate-finicity-signature'
|
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 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'
|
||||||
|
@ -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(
|
router.get(
|
||||||
'/subscription',
|
'/subscription',
|
||||||
endpoint.create({
|
endpoint.create({
|
||||||
|
@ -286,43 +259,7 @@ router.get(
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: Remove this endpoint or refactor to work with new Auth
|
// TODO: Implement verification email using Postmark instead of Auth0
|
||||||
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
|
|
||||||
router.post(
|
router.post(
|
||||||
'/resend-verification-email',
|
'/resend-verification-email',
|
||||||
endpoint.create({
|
endpoint.create({
|
||||||
|
@ -333,7 +270,7 @@ router.post(
|
||||||
const authId = input.authId ?? ctx.user?.authId
|
const authId = input.authId ?? ctx.user?.authId
|
||||||
if (!authId) throw new Error('User not found')
|
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}`)
|
ctx.logger.info(`Sent verification email to ${authId}`)
|
||||||
|
|
||||||
|
@ -354,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 }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -439,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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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 './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()
|
return (
|
||||||
const profileQuery = useAuth0Profile()
|
|
||||||
|
|
||||||
if (profileQuery.isLoading) {
|
|
||||||
return <LoadingSpinner />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profileQuery.isError) {
|
|
||||||
return (
|
|
||||||
<p className="text-gray-50">
|
|
||||||
Something went wrong loading your security preferences...
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { socialOnlyUser, mfaEnabled } = profileQuery.data
|
|
||||||
|
|
||||||
return socialOnlyUser ? (
|
|
||||||
<>
|
|
||||||
<p className="text-base text-white">
|
|
||||||
Your account credentials are managed by Apple. To reset your password, click the
|
|
||||||
button below to go to your Apple settings.
|
|
||||||
</p>
|
|
||||||
<Button className="mt-4" href="https://appleid.apple.com/">
|
|
||||||
Manage Apple Account
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
<h4 className="mb-2 text-lg uppercase">Password</h4>
|
<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,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`,
|
||||||
|
@ -70,67 +64,11 @@ const UserApi = (
|
||||||
return data
|
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() {
|
async getSubscription() {
|
||||||
const { data } = await axios.get<SharedType.UserSubscription>('/users/subscription')
|
const { data } = await axios.get<SharedType.UserSubscription>('/users/subscription')
|
||||||
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,
|
||||||
|
@ -139,32 +77,6 @@ const UserApi = (
|
||||||
return data
|
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) {
|
async resendEmailVerification(authId?: string) {
|
||||||
const { data } = await axios.post<{ success: boolean }>(
|
const { data } = await axios.post<{ success: boolean }>(
|
||||||
'/users/resend-verification-email',
|
'/users/resend-verification-email',
|
||||||
|
@ -206,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 },
|
||||||
|
@ -304,24 +215,6 @@ export function useUserApi() {
|
||||||
...options,
|
...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 = (
|
const useSubscription = (
|
||||||
options?: Omit<UseQueryOptions<SharedType.UserSubscription>, 'queryKey' | 'queryFn'>
|
options?: Omit<UseQueryOptions<SharedType.UserSubscription>, 'queryKey' | 'queryFn'>
|
||||||
) => useQuery(['users', 'subscription'], api.getSubscription, options)
|
) => 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 = (
|
const useResendEmailVerification = (
|
||||||
options?: UseMutationOptions<{ success: boolean } | undefined, unknown, string | undefined>
|
options?: UseMutationOptions<{ success: boolean } | undefined, unknown, string | undefined>
|
||||||
) =>
|
) =>
|
||||||
|
@ -420,12 +286,8 @@ export function useUserApi() {
|
||||||
useProfile,
|
useProfile,
|
||||||
useUpdateProfile,
|
useUpdateProfile,
|
||||||
useAuthProfile,
|
useAuthProfile,
|
||||||
useAuth0Profile,
|
|
||||||
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,3 +1,2 @@
|
||||||
export * from './FeedbackDialog'
|
export * from './FeedbackDialog'
|
||||||
export * from './ConfirmDialog'
|
|
||||||
export * from './NonUSDDialog'
|
export * from './NonUSDDialog'
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import type { AuthUser, PrismaClient, Prisma } from '@prisma/client'
|
import type { AuthUser, PrismaClient, Prisma } from '@prisma/client'
|
||||||
import type { Logger } from 'winston'
|
import type { Logger } from 'winston'
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
|
||||||
export interface IAuthUserService {
|
export interface IAuthUserService {
|
||||||
get(id: AuthUser['id']): Promise<AuthUser>
|
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 }) {
|
async create(data: Prisma.AuthUserCreateInput & { firstName: string; lastName: string }) {
|
||||||
const authUser = await this.prisma.authUser.create({ data: { ...data } })
|
const authUser = await this.prisma.authUser.create({ data: { ...data } })
|
||||||
return authUser
|
return authUser
|
||||||
|
|
|
@ -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
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import type { AccountCategory, AccountType, PrismaClient, User } from '@prisma/client'
|
import type { AccountCategory, AccountType, PrismaClient, User } from '@prisma/client'
|
||||||
import type { Logger } from 'winston'
|
import type { Logger } from 'winston'
|
||||||
import { AuthUtil, type PurgeUserQueue, type SyncUserQueue } from '@maybe-finance/server/shared'
|
import type { PurgeUserQueue, 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'
|
||||||
|
@ -55,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
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@ -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']) {
|
async sync(id: User['id']) {
|
||||||
const user = await this.get(id)
|
const user = await this.get(id)
|
||||||
await this.syncQueue.add('sync-user', { userId: user.id })
|
await this.syncQueue.add('sync-user', { userId: user.id })
|
||||||
|
@ -554,46 +508,4 @@ export class UserService implements IUserService {
|
||||||
|
|
||||||
return onboarding
|
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 {
|
import type {
|
||||||
AccountCategory,
|
AccountCategory,
|
||||||
AccountClassification,
|
AccountClassification,
|
||||||
|
@ -191,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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue