1
0
Fork 0
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:
Tyler Myracle 2024-01-14 10:55:59 -06:00
parent 7267c30fc9
commit 6100ab14c6
37 changed files with 40 additions and 1283 deletions

View file

@ -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=

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

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

View file

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

View file

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

View file

@ -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, {

View file

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

View file

@ -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),

View file

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

View file

@ -2,7 +2,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'

View file

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

View file

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

View file

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

View file

@ -1,6 +1,4 @@
import { Router } from 'express'
import axios from 'axios'
import type { UnlinkAccountsParamsProvider } from 'auth0'
import { subject } from '@casl/ability'
import { z } from 'zod'
import { DateUtil, type SharedType } from '@maybe-finance/shared'
@ -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,
}),
})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,47 +1,12 @@
import { useUserApi } from '@maybe-finance/client/shared'
import { Button, LoadingSpinner } from '@maybe-finance/design-system'
import { MultiFactorAuthentication } from './MultiFactorAuthentication'
import { PasswordReset } from './PasswordReset'
export function SecurityPreferences() {
const { useAuth0Profile } = useUserApi()
const profileQuery = useAuth0Profile()
if (profileQuery.isLoading) {
return <LoadingSpinner />
}
if (profileQuery.isError) {
return (
<p className="text-gray-50">
Something went wrong loading your security preferences...
</p>
)
}
const { socialOnlyUser, mfaEnabled } = profileQuery.data
return socialOnlyUser ? (
<>
<p className="text-base text-white">
Your account credentials are managed by Apple. To reset your password, click the
button below to go to your Apple settings.
</p>
<Button className="mt-4" href="https://appleid.apple.com/">
Manage Apple Account
</Button>
</>
) : (
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} />
</>
)
}

View file

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

View file

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

View file

@ -1,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,

View file

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

View file

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

View file

@ -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

View file

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

View file

@ -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
}
}

View file

@ -1,5 +1,3 @@
import type { User as Auth0UserClient } from '@auth0/auth0-react'
import type { Identity, User as Auth0UserServer } from 'auth0'
import type {
AccountCategory,
AccountClassification,
@ -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