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 into feat-demo-user

This commit is contained in:
Karan Handa 2024-01-22 05:37:25 +05:30
commit 6ae5240e9b
21 changed files with 162 additions and 111 deletions

14
apps/client/.babelrc.json Normal file
View file

@ -0,0 +1,14 @@
{
"presets": [
"@babel/preset-typescript",
"@babel/preset-env",
[
"@nrwl/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}

View file

@ -0,0 +1,18 @@
const rootMain = require('../../../.storybook/main')
module.exports = {
...rootMain,
core: { ...rootMain.core, builder: 'webpack5' },
stories: ['../**/*.stories.@(js|jsx|ts|tsx)'],
addons: [...rootMain.addons, '@nrwl/react/plugins/storybook'],
webpackFinal: async (config, { configType }) => {
// apply any global webpack configs that might have been specified in .storybook/main.js
if (rootMain.webpackFinal) {
config = await rootMain.webpackFinal(config, { configType })
}
// add your own webpack tweaks if needed
return config
},
}

View file

@ -0,0 +1,5 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {},
"include": ["**/*.ts", "**/*.tsx", "**/**/*.ts", "**/**/*.tsx"]
}

View file

@ -0,0 +1,18 @@
import type { Story, Meta } from '@storybook/react'
import Maintenance from './Maintenance.tsx'
import React from 'react'
export default {
title: 'components/Maintenance.tsx',
component: Maintenance,
} as Meta
const Template: Story = () => {
return (
<>
<Maintenance />
</>
)
}
export const Base = Template.bind({})

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,14 @@ 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
const { firstName, lastName, email, password, isAdmin } = await validateCredentials(
{
...credentials,
isAdmin: Boolean(credentials?.isAdmin),
}
)
const existingUser = await getAuthUserByEmail(email)
@ -114,7 +123,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 +135,7 @@ export const authOptions = {
token.firstName = authUser.firstName
token.lastName = authUser.lastName
token.name = authUser.name
token.role = authUser.role
}
return token
},
@ -136,6 +146,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

@ -27,5 +27,17 @@
"next-env.d.ts",
".next/types/**/*.ts"
],
"exclude": ["node_modules", "jest.config.ts"]
"exclude": [
"node_modules",
"jest.config.ts",
"**/*.stories.ts",
"**/*.stories.js",
"**/*.stories.jsx",
"**/*.stories.tsx"
],
"references": [
{
"path": "./.storybook/tsconfig.json"
}
]
}

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

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

View file

@ -66,6 +66,33 @@
"options": {
"command": "node tools/scripts/triggerClientDeploy.js"
}
},
"storybook": {
"executor": "@nrwl/storybook:storybook",
"options": {
"uiFramework": "@storybook/react",
"port": 4400,
"configDir": "apps/client/.storybook"
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"executor": "@nrwl/storybook:build",
"outputs": ["{options.outputDir}"],
"options": {
"uiFramework": "@storybook/react",
"outputDir": "dist/storybook/client",
"configDir": "apps/client/.storybook"
},
"configurations": {
"ci": {
"quiet": true
}
}
}
},
"tags": ["scope:app"]

View file

@ -20432,4 +20432,4 @@ zod@^3.19.1:
zwitch@^1.0.0:
version "1.0.5"
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"
integrity sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==
integrity sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==