1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 15:35:22 +02:00

Merge branch 'main' of github.com:maybe-finance/maybe

This commit is contained in:
Karan Handa 2024-01-22 20:58:22 +05:30
commit 962df2ef95
15 changed files with 67 additions and 110 deletions

View file

@ -2,7 +2,8 @@ import NextAuth from 'next-auth'
import type { SessionStrategy, NextAuthOptions } from 'next-auth' import type { SessionStrategy, NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials' import CredentialsProvider from 'next-auth/providers/credentials'
import { z } from 'zod' import { z } from 'zod'
import { PrismaClient, type Prisma } from '@prisma/client' import { PrismaClient, AuthUserRole } from '@prisma/client'
import type { Prisma } from '@prisma/client'
import { PrismaAdapter } from '@auth/prisma-adapter' import { PrismaAdapter } from '@auth/prisma-adapter'
import type { SharedType } from '@maybe-finance/shared' import type { SharedType } from '@maybe-finance/shared'
import bcrypt from 'bcrypt' import bcrypt from 'bcrypt'
@ -36,6 +37,7 @@ async function validateCredentials(credentials: any): Promise<z.infer<typeof aut
lastName: z.string().optional(), lastName: z.string().optional(),
email: z.string().email({ message: 'Invalid email address.' }), email: z.string().email({ message: 'Invalid email address.' }),
password: z.string().min(6), password: z.string().min(6),
isAdmin: z.boolean().default(false),
}) })
const parsed = authSchema.safeParse(credentials) const parsed = authSchema.safeParse(credentials)
@ -51,13 +53,15 @@ async function createNewAuthUser(credentials: {
lastName: string lastName: string
email: string email: string
password: string password: string
isAdmin: boolean
}): Promise<SharedType.AuthUser> { }): Promise<SharedType.AuthUser> {
const { firstName, lastName, email, password } = credentials const { firstName, lastName, email, password, isAdmin } = credentials
if (!firstName || !lastName) { if (!firstName || !lastName) {
throw new Error('Both first name and last name are required.') throw new Error('Both first name and last name are required.')
} }
const isDevelopment = process.env.NODE_ENV === 'development'
const hashedPassword = await bcrypt.hash(password, 10) const hashedPassword = await bcrypt.hash(password, 10)
return createAuthUser({ return createAuthUser({
firstName, firstName,
@ -65,6 +69,7 @@ async function createNewAuthUser(credentials: {
name: `${firstName} ${lastName}`, name: `${firstName} ${lastName}`,
email, email,
password: hashedPassword, password: hashedPassword,
role: isAdmin && isDevelopment ? AuthUserRole.admin : AuthUserRole.user,
}) })
} }
@ -94,10 +99,14 @@ export const authOptions = {
lastName: { label: 'Last name', type: 'text', placeholder: 'Last name' }, lastName: { label: 'Last name', type: 'text', placeholder: 'Last name' },
email: { label: 'Email', type: 'email', placeholder: 'hello@maybe.co' }, email: { label: 'Email', type: 'email', placeholder: 'hello@maybe.co' },
password: { label: 'Password', type: 'password' }, password: { label: 'Password', type: 'password' },
isAdmin: { label: 'Admin', type: 'checkbox' },
}, },
async authorize(credentials) { async authorize(credentials) {
const { firstName, lastName, email, password } = await validateCredentials( const { firstName, lastName, email, password, isAdmin } = await validateCredentials(
credentials {
...credentials,
isAdmin: Boolean(credentials?.isAdmin),
}
) )
const existingUser = await getAuthUserByEmail(email) const existingUser = await getAuthUserByEmail(email)
@ -114,7 +123,7 @@ export const authOptions = {
throw new Error('Invalid credentials provided.') throw new Error('Invalid credentials provided.')
} }
return createNewAuthUser({ firstName, lastName, email, password }) return createNewAuthUser({ firstName, lastName, email, password, isAdmin })
}, },
}), }),
], ],
@ -126,6 +135,7 @@ export const authOptions = {
token.firstName = authUser.firstName token.firstName = authUser.firstName
token.lastName = authUser.lastName token.lastName = authUser.lastName
token.name = authUser.name token.name = authUser.name
token.role = authUser.role
} }
return token return token
}, },
@ -136,6 +146,7 @@ export const authOptions = {
session.firstName = token.firstName session.firstName = token.firstName
session.lastName = token.lastName session.lastName = token.lastName
session.name = token.name session.name = token.name
session.role = token.role
return session return session
}, },
}, },

View file

@ -1,5 +1,5 @@
import { useState, type ReactElement } from 'react' import { useState, type ReactElement } from 'react'
import { Input, InputPassword, Button } from '@maybe-finance/design-system' import { Input, InputPassword, Button, Checkbox } from '@maybe-finance/design-system'
import { FullPageLayout } from '@maybe-finance/client/features' import { FullPageLayout } from '@maybe-finance/client/features'
import { signIn, useSession } from 'next-auth/react' import { signIn, useSession } from 'next-auth/react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
@ -15,6 +15,7 @@ export default function RegisterPage() {
const [isValid, setIsValid] = useState(false) const [isValid, setIsValid] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isAdmin, setIsAdmin] = useState(false)
const { data: session } = useSession() const { data: session } = useSession()
const router = useRouter() const router = useRouter()
@ -38,6 +39,7 @@ export default function RegisterPage() {
password, password,
firstName, firstName,
lastName, lastName,
isAdmin,
redirect: false, redirect: false,
}) })
@ -108,6 +110,8 @@ export default function RegisterPage() {
</div> </div>
) : null} ) : null}
<AuthDevTools isAdmin={isAdmin} setIsAdmin={setIsAdmin} />
<Button <Button
type="submit" type="submit"
disabled={!isValid} disabled={!isValid}
@ -135,6 +139,28 @@ export default function RegisterPage() {
) )
} }
type AuthDevToolsProps = {
isAdmin: boolean
setIsAdmin: (isAdmin: boolean) => void
}
function AuthDevTools({ isAdmin, setIsAdmin }: AuthDevToolsProps) {
return process.env.NODE_ENV === 'development' ? (
<div className="my-2 p-2 border border-red-300 rounded-md">
<h6 className="flex text-red">
Dev Tools <i className="ri-tools-fill ml-1.5" />
</h6>
<p className="text-sm my-2">
This section will NOT show in production and is solely for making testing easier.
</p>
<div>
<Checkbox checked={isAdmin} onChange={setIsAdmin} label="Create Admin user?" />
</div>
</div>
) : null
}
RegisterPage.getLayout = function getLayout(page: ReactElement) { RegisterPage.getLayout = function getLayout(page: ReactElement) {
return <FullPageLayout>{page}</FullPageLayout> return <FullPageLayout>{page}</FullPageLayout>
} }

View file

@ -2,7 +2,6 @@ import type { Subjects } from '@casl/prisma'
import { PrismaAbility, accessibleBy } from '@casl/prisma' import { PrismaAbility, accessibleBy } from '@casl/prisma'
import type { AbilityClass } from '@casl/ability' import type { AbilityClass } from '@casl/ability'
import { AbilityBuilder, ForbiddenError } from '@casl/ability' import { AbilityBuilder, ForbiddenError } from '@casl/ability'
import type { SharedType } from '@maybe-finance/shared'
import type { import type {
User, User,
Account, Account,
@ -18,6 +17,7 @@ import type {
ProviderInstitution, ProviderInstitution,
Plan, Plan,
} from '@prisma/client' } from '@prisma/client'
import { AuthUserRole } from '@prisma/client'
type CRUDActions = 'create' | 'read' | 'update' | 'delete' type CRUDActions = 'create' | 'read' | 'update' | 'delete'
type AppActions = CRUDActions | 'manage' type AppActions = CRUDActions | 'manage'
@ -41,13 +41,11 @@ type AppSubjects = PrismaSubjects | 'all'
type AppAbility = PrismaAbility<[AppActions, AppSubjects]> type AppAbility = PrismaAbility<[AppActions, AppSubjects]>
export default function defineAbilityFor( export default function defineAbilityFor(user: (Pick<User, 'id'> & { role: AuthUserRole }) | null) {
user: (Pick<User, 'id'> & { roles: SharedType.UserRole[] }) | null
) {
const { can, build } = new AbilityBuilder(PrismaAbility as AbilityClass<AppAbility>) const { can, build } = new AbilityBuilder(PrismaAbility as AbilityClass<AppAbility>)
if (user) { if (user) {
if (user.roles.includes('Admin')) { if (user.role === AuthUserRole.admin) {
can('manage', 'Account') can('manage', 'Account')
can('manage', 'AccountConnection') can('manage', 'AccountConnection')
can('manage', 'Valuation') can('manage', 'Valuation')

View file

@ -301,15 +301,11 @@ async function getCurrentUser(jwt: NonNullable<Request['user']>) {
return { return {
...user, ...user,
role: jwt.role,
// TODO: Replace Auth0 concepts with next-auth // TODO: Replace Auth0 concepts with next-auth
roles: [],
primaryIdentity: {}, primaryIdentity: {},
userMetadata: {}, userMetadata: {},
appMetadata: {}, appMetadata: {},
// roles: jwt[SharedType.Auth0CustomNamespace.Roles] ?? [],
// primaryIdentity: jwt[SharedType.Auth0CustomNamespace.PrimaryIdentity] ?? {},
// userMetadata: jwt[SharedType.Auth0CustomNamespace.UserMetadata] ?? {},
// appMetadata: jwt[SharedType.Auth0CustomNamespace.AppMetadata] ?? {},
} }
} }

View file

@ -1,12 +1,9 @@
import { Router } from 'express' import { Router } from 'express'
import { auth, claimCheck } from 'express-openid-connect'
import { createBullBoard } from '@bull-board/api' import { createBullBoard } from '@bull-board/api'
import { BullAdapter } from '@bull-board/api/bullAdapter' import { BullAdapter } from '@bull-board/api/bullAdapter'
import { ExpressAdapter } from '@bull-board/express' import { ExpressAdapter } from '@bull-board/express'
import { AuthUtil, BullQueue } from '@maybe-finance/server/shared' import { BullQueue } from '@maybe-finance/server/shared'
import { SharedType } from '@maybe-finance/shared'
import { queueService } from '../lib/endpoint' import { queueService } from '../lib/endpoint'
import env from '../../env'
import { validateAuthJwt } from '../middleware' import { validateAuthJwt } from '../middleware'
const router = Router() const router = Router()

View file

@ -27,10 +27,10 @@ router.post(
resolve: async ({ ctx }) => { resolve: async ({ ctx }) => {
ctx.ability.throwUnlessCan('update', 'Institution') ctx.ability.throwUnlessCan('update', 'Institution')
// Sync all Plaid institutions // Sync all Teller institutions
await ctx.queueService await ctx.queueService
.getQueue('sync-institution') .getQueue('sync-institution')
.addBulk([{ name: 'sync-plaid-institutions', data: {} }]) .addBulk([{ name: 'sync-teller-institutions', data: {} }])
return { success: true } return { success: true }
}, },

View file

@ -1,23 +1,16 @@
import Link from 'next/link' import Link from 'next/link'
import { import { useAccountConnectionApi, useInstitutionApi } from '@maybe-finance/client/shared'
useAccountConnectionApi,
useInstitutionApi,
usePlaidApi,
BrowserUtil,
} from '@maybe-finance/client/shared'
export function AccountDevTools() { export function AccountDevTools() {
const { useSandboxQuickAdd } = usePlaidApi()
const { useDeleteAllConnections } = useAccountConnectionApi() const { useDeleteAllConnections } = useAccountConnectionApi()
const { useSyncInstitutions, useDeduplicateInstitutions } = useInstitutionApi() const { useSyncInstitutions, useDeduplicateInstitutions } = useInstitutionApi()
const sandboxQuickAdd = useSandboxQuickAdd()
const deleteAllConnections = useDeleteAllConnections() const deleteAllConnections = useDeleteAllConnections()
const syncInstitutions = useSyncInstitutions() const syncInstitutions = useSyncInstitutions()
const deduplicateInstitutions = useDeduplicateInstitutions() const deduplicateInstitutions = useDeduplicateInstitutions()
return process.env.NODE_ENV === 'development' ? ( return process.env.NODE_ENV === 'development' ? (
<div className="relative mb-12 mx-2 sm:mx-0 p-4 bg-gray-700 rounded-md"> <div className="relative mb-12 mx-2 sm:mx-0 p-4 bg-gray-700 rounded-md z-10">
<h6 className="flex text-red"> <h6 className="flex text-red">
Dev Tools <i className="ri-tools-fill ml-1.5" /> Dev Tools <i className="ri-tools-fill ml-1.5" />
</h6> </h6>
@ -27,12 +20,6 @@ export function AccountDevTools() {
</p> </p>
<div className="flex items-center text-sm mt-4"> <div className="flex items-center text-sm mt-4">
<p className="font-bold">Actions:</p> <p className="font-bold">Actions:</p>
<button
className="underline text-red ml-4"
onClick={() => sandboxQuickAdd.mutate()}
>
Quick add item
</button>
<button <button
className="underline text-red ml-4" className="underline text-red ml-4"
onClick={() => deleteAllConnections.mutate()} onClick={() => deleteAllConnections.mutate()}
@ -42,12 +29,6 @@ export function AccountDevTools() {
<Link href="http://localhost:3333/admin/bullmq" className="underline text-red ml-4"> <Link href="http://localhost:3333/admin/bullmq" className="underline text-red ml-4">
BullMQ Dashboard BullMQ Dashboard
</Link> </Link>
<button
className="underline text-red ml-4"
onClick={() => BrowserUtil.copyToClipboard((window as any).JWT)}
>
Copy JWT
</button>
<button <button
className="underline text-red ml-4" className="underline text-red ml-4"
onClick={() => syncInstitutions.mutate()} onClick={() => syncInstitutions.mutate()}

View file

@ -57,7 +57,7 @@ export default function AccountTypeSelector({
type="text" type="text"
placeholder="Search for an institution" placeholder="Search for an institution"
fixedLeftOverride={<RiSearchLine className="w-5 h-5" />} fixedLeftOverride={<RiSearchLine className="w-5 h-5" />}
inputClassName="pl-10" inputClassName="pl-11"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
ref={inputRef} ref={inputRef}

View file

@ -70,6 +70,7 @@ export class InMemoryQueueFactory implements IQueueFactory {
constructor( constructor(
private readonly ignoreJobNames: string[] = [ private readonly ignoreJobNames: string[] = [
'sync-all-securities', 'sync-all-securities',
'sync-teller-institutions',
'sync-plaid-institutions', 'sync-plaid-institutions',
'trial-reminders', 'trial-reminders',
'send-email', 'send-email',

View file

@ -1,60 +0,0 @@
import jwks from 'jwks-rsa'
import jwt from 'jsonwebtoken'
import { SharedType } from '@maybe-finance/shared'
export const verifyRoleClaims = (claims, role: SharedType.UserRole) => {
const customRoleClaim = claims[SharedType.Auth0CustomNamespace.Roles]
return customRoleClaim && Array.isArray(customRoleClaim) && customRoleClaim.includes(role)
}
export async function validateRS256JWT(
token: string,
domain: string,
audience: string
): Promise<{
authId: string
userMetadata: SharedType.MaybeUserMetadata
appMetadata: SharedType.MaybeAppMetadata
}> {
const jwksClient = jwks({
rateLimit: true,
jwksUri: `https://${domain}/.well-known/jwks.json`,
})
return new Promise((resolve, reject) => {
if (!token) reject('No token provided')
const parts = token.split(' ')
if (!parts || parts.length !== 2) reject('JWT must be in format: Bearer <token>')
if (parts[0] !== 'Bearer') reject('JWT must be in format: Bearer <token>')
const rawToken = parts[1]
jwt.verify(
rawToken,
(header, cb) => {
jwksClient
.getSigningKey(header.kid)
.then((key) => cb(null, key.getPublicKey()))
.catch((err) => cb(err))
},
{
audience,
issuer: `https://${domain}/`,
algorithms: ['RS256'],
},
(err, payload) => {
if (err) return reject(err)
if (typeof payload !== 'object') return reject('payload not an object')
resolve({
authId: payload.sub!,
appMetadata: payload[SharedType.Auth0CustomNamespace.AppMetadata] ?? {},
userMetadata: payload[SharedType.Auth0CustomNamespace.UserMetadata] ?? {},
})
}
)
})
}

View file

@ -1,4 +1,3 @@
export * as AuthUtil from './auth-utils'
export * as DbUtil from './db-utils' export * as DbUtil from './db-utils'
export * as PlaidUtil from './plaid-utils' export * as PlaidUtil from './plaid-utils'
export * as TellerUtil from './teller-utils' export * as TellerUtil from './teller-utils'

View file

@ -170,9 +170,6 @@ export type MaybeUserMetadata = Partial<{
// Maybe's "normalized" Auth0 `user.app_metadata` object // Maybe's "normalized" Auth0 `user.app_metadata` object
export type MaybeAppMetadata = {} export type MaybeAppMetadata = {}
// The custom roles we have defined in Auth0
export type UserRole = 'Admin' | 'CIUser'
export type PrimaryAuth0Identity = Partial<{ export type PrimaryAuth0Identity = Partial<{
connection: string connection: string
provider: string provider: string
@ -183,7 +180,6 @@ export type PrimaryAuth0Identity = Partial<{
export type MaybeCustomClaims = { export type MaybeCustomClaims = {
[Auth0CustomNamespace.Email]?: string | null [Auth0CustomNamespace.Email]?: string | null
[Auth0CustomNamespace.Picture]?: string | null [Auth0CustomNamespace.Picture]?: string | null
[Auth0CustomNamespace.Roles]?: UserRole[]
[Auth0CustomNamespace.UserMetadata]?: MaybeUserMetadata [Auth0CustomNamespace.UserMetadata]?: MaybeUserMetadata
[Auth0CustomNamespace.AppMetadata]?: MaybeAppMetadata [Auth0CustomNamespace.AppMetadata]?: MaybeAppMetadata
[Auth0CustomNamespace.PrimaryIdentity]?: PrimaryAuth0Identity [Auth0CustomNamespace.PrimaryIdentity]?: PrimaryAuth0Identity

View file

@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "AuthUserRole" AS ENUM ('user', 'admin', 'ci');
-- AlterTable
ALTER TABLE "auth_user" ADD COLUMN "role" "AuthUserRole" NOT NULL DEFAULT 'user';

View file

@ -573,6 +573,12 @@ model AuthAccount {
@@map("auth_account") @@map("auth_account")
} }
enum AuthUserRole {
user
admin
ci
}
model AuthUser { model AuthUser {
id String @id @default(cuid()) id String @id @default(cuid())
name String? name String?
@ -582,6 +588,7 @@ model AuthUser {
emailVerified DateTime? @map("email_verified") emailVerified DateTime? @map("email_verified")
password String? password String?
image String? image String?
role AuthUserRole @default(user)
accounts AuthAccount[] accounts AuthAccount[]
sessions AuthSession[] sessions AuthSession[]