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

fix dev tools and add admin role

This commit is contained in:
Tyler Myracle 2024-01-21 15:36:29 -06:00
parent 0d5d7d5a7f
commit f9768f7e08
14 changed files with 67 additions and 109 deletions

View file

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

View file

@ -1,5 +1,5 @@
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 { signIn, useSession } from 'next-auth/react'
import { useRouter } from 'next/router'
@ -15,6 +15,7 @@ export default function RegisterPage() {
const [isValid, setIsValid] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isAdmin, setIsAdmin] = useState(false)
const { data: session } = useSession()
const router = useRouter()
@ -38,6 +39,7 @@ export default function RegisterPage() {
password,
firstName,
lastName,
isAdmin,
redirect: false,
})
@ -108,6 +110,8 @@ export default function RegisterPage() {
</div>
) : null}
<AuthDevTools isAdmin={isAdmin} setIsAdmin={setIsAdmin} />
<Button
type="submit"
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) {
return <FullPageLayout>{page}</FullPageLayout>
}

View file

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

View file

@ -301,15 +301,11 @@ async function getCurrentUser(jwt: NonNullable<Request['user']>) {
return {
...user,
role: jwt.role,
// TODO: Replace Auth0 concepts with next-auth
roles: [],
primaryIdentity: {},
userMetadata: {},
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 { 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 { BullQueue } from '@maybe-finance/server/shared'
import { queueService } from '../lib/endpoint'
import env from '../../env'
import { validateAuthJwt } from '../middleware'
const router = Router()

View file

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

View file

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

View file

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

View file

@ -70,6 +70,7 @@ export class InMemoryQueueFactory implements IQueueFactory {
constructor(
private readonly ignoreJobNames: string[] = [
'sync-all-securities',
'sync-teller-institutions',
'sync-plaid-institutions',
'trial-reminders',
'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 PlaidUtil from './plaid-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
export type MaybeAppMetadata = {}
// The custom roles we have defined in Auth0
export type UserRole = 'Admin' | 'CIUser'
export type PrimaryAuth0Identity = Partial<{
connection: string
provider: string
@ -183,7 +180,6 @@ export type PrimaryAuth0Identity = Partial<{
export type MaybeCustomClaims = {
[Auth0CustomNamespace.Email]?: string | null
[Auth0CustomNamespace.Picture]?: string | null
[Auth0CustomNamespace.Roles]?: UserRole[]
[Auth0CustomNamespace.UserMetadata]?: MaybeUserMetadata
[Auth0CustomNamespace.AppMetadata]?: MaybeAppMetadata
[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

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