diff --git a/apps/client/.babelrc.json b/apps/client/.babelrc.json new file mode 100644 index 00000000..86efa43b --- /dev/null +++ b/apps/client/.babelrc.json @@ -0,0 +1,14 @@ +{ + "presets": [ + "@babel/preset-typescript", + "@babel/preset-env", + [ + "@nrwl/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/apps/client/.storybook/main.js b/apps/client/.storybook/main.js new file mode 100644 index 00000000..2484425c --- /dev/null +++ b/apps/client/.storybook/main.js @@ -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 + }, +} diff --git a/apps/client/.storybook/tsconfig.json b/apps/client/.storybook/tsconfig.json new file mode 100644 index 00000000..61b74d70 --- /dev/null +++ b/apps/client/.storybook/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": {}, + "include": ["**/*.ts", "**/*.tsx", "**/**/*.ts", "**/**/*.tsx"] +} diff --git a/apps/client/components/Maintenance.stories.tsx b/apps/client/components/Maintenance.stories.tsx new file mode 100644 index 00000000..468d8d2a --- /dev/null +++ b/apps/client/components/Maintenance.stories.tsx @@ -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 ( + <> + + > + ) +} + +export const Base = Template.bind({}) diff --git a/apps/client/pages/api/auth/[...nextauth].ts b/apps/client/pages/api/auth/[...nextauth].ts index a641f0ac..485d75cb 100644 --- a/apps/client/pages/api/auth/[...nextauth].ts +++ b/apps/client/pages/api/auth/[...nextauth].ts @@ -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 { - 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 }, }, diff --git a/apps/client/pages/register.tsx b/apps/client/pages/register.tsx index 2552a3dd..6a0a64f5 100644 --- a/apps/client/pages/register.tsx +++ b/apps/client/pages/register.tsx @@ -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(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() { ) : null} + + void +} + +function AuthDevTools({ isAdmin, setIsAdmin }: AuthDevToolsProps) { + return process.env.NODE_ENV === 'development' ? ( + + + Dev Tools + + + This section will NOT show in production and is solely for making testing easier. + + + + + + + ) : null +} + RegisterPage.getLayout = function getLayout(page: ReactElement) { return {page} } diff --git a/apps/client/tsconfig.json b/apps/client/tsconfig.json index d69a004a..b836c388 100644 --- a/apps/client/tsconfig.json +++ b/apps/client/tsconfig.json @@ -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" + } + ] } diff --git a/apps/server/src/app/lib/ability.ts b/apps/server/src/app/lib/ability.ts index 9a3cfed8..16b74065 100644 --- a/apps/server/src/app/lib/ability.ts +++ b/apps/server/src/app/lib/ability.ts @@ -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 & { roles: SharedType.UserRole[] }) | null -) { +export default function defineAbilityFor(user: (Pick & { role: AuthUserRole }) | null) { const { can, build } = new AbilityBuilder(PrismaAbility as AbilityClass) if (user) { - if (user.roles.includes('Admin')) { + if (user.role === AuthUserRole.admin) { can('manage', 'Account') can('manage', 'AccountConnection') can('manage', 'Valuation') diff --git a/apps/server/src/app/lib/endpoint.ts b/apps/server/src/app/lib/endpoint.ts index 7fba5f3c..91d0948c 100644 --- a/apps/server/src/app/lib/endpoint.ts +++ b/apps/server/src/app/lib/endpoint.ts @@ -301,15 +301,11 @@ async function getCurrentUser(jwt: NonNullable) { 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] ?? {}, } } diff --git a/apps/server/src/app/routes/admin.router.ts b/apps/server/src/app/routes/admin.router.ts index dd17b6d9..4303e867 100644 --- a/apps/server/src/app/routes/admin.router.ts +++ b/apps/server/src/app/routes/admin.router.ts @@ -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() diff --git a/apps/server/src/app/routes/institutions.router.ts b/apps/server/src/app/routes/institutions.router.ts index 19e2a937..dd94b499 100644 --- a/apps/server/src/app/routes/institutions.router.ts +++ b/apps/server/src/app/routes/institutions.router.ts @@ -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 } }, diff --git a/libs/client/features/src/accounts-list/AccountDevTools.tsx b/libs/client/features/src/accounts-list/AccountDevTools.tsx index 6dbf05a6..c417196d 100644 --- a/libs/client/features/src/accounts-list/AccountDevTools.tsx +++ b/libs/client/features/src/accounts-list/AccountDevTools.tsx @@ -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' ? ( - + Dev Tools @@ -27,12 +20,6 @@ export function AccountDevTools() { Actions: - sandboxQuickAdd.mutate()} - > - Quick add item - deleteAllConnections.mutate()} @@ -42,12 +29,6 @@ export function AccountDevTools() { BullMQ Dashboard - BrowserUtil.copyToClipboard((window as any).JWT)} - > - Copy JWT - syncInstitutions.mutate()} diff --git a/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx b/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx index a66b5c18..b4bd3bb9 100644 --- a/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx +++ b/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx @@ -57,7 +57,7 @@ export default function AccountTypeSelector({ type="text" placeholder="Search for an institution" fixedLeftOverride={} - inputClassName="pl-10" + inputClassName="pl-11" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} ref={inputRef} diff --git a/libs/server/shared/src/services/queue/in-memory-queue.ts b/libs/server/shared/src/services/queue/in-memory-queue.ts index 034b5d76..eb45a6d0 100644 --- a/libs/server/shared/src/services/queue/in-memory-queue.ts +++ b/libs/server/shared/src/services/queue/in-memory-queue.ts @@ -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', diff --git a/libs/server/shared/src/utils/auth-utils.ts b/libs/server/shared/src/utils/auth-utils.ts deleted file mode 100644 index 42c509c5..00000000 --- a/libs/server/shared/src/utils/auth-utils.ts +++ /dev/null @@ -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 ') - if (parts[0] !== 'Bearer') reject('JWT must be in format: Bearer ') - - 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] ?? {}, - }) - } - ) - }) -} diff --git a/libs/server/shared/src/utils/index.ts b/libs/server/shared/src/utils/index.ts index 62ccba24..97b70d16 100644 --- a/libs/server/shared/src/utils/index.ts +++ b/libs/server/shared/src/utils/index.ts @@ -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' diff --git a/libs/shared/src/types/user-types.ts b/libs/shared/src/types/user-types.ts index ddea52a8..983ffaa2 100644 --- a/libs/shared/src/types/user-types.ts +++ b/libs/shared/src/types/user-types.ts @@ -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 diff --git a/prisma/migrations/20240121204146_add_auth_user_role/migration.sql b/prisma/migrations/20240121204146_add_auth_user_role/migration.sql new file mode 100644 index 00000000..d0573c70 --- /dev/null +++ b/prisma/migrations/20240121204146_add_auth_user_role/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bebee78d..58e8cc40 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[] diff --git a/workspace.json b/workspace.json index ef681dc4..64b2eb15 100644 --- a/workspace.json +++ b/workspace.json @@ -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"] diff --git a/yarn.lock b/yarn.lock index 06e3d78f..be6dd4ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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== \ No newline at end of file
+ This section will NOT show in production and is solely for making testing easier. +
Actions: