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:
parent
0d5d7d5a7f
commit
f9768f7e08
14 changed files with 67 additions and 109 deletions
|
@ -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
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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] ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 }
|
||||
},
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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] ?? {},
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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';
|
|
@ -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[]
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue