├── apps ├── api │ ├── src │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── common │ │ │ ├── constants │ │ │ │ ├── index.ts │ │ │ │ └── auth.ts │ │ │ ├── decorators │ │ │ │ ├── index.ts │ │ │ │ └── reqUser.decorator.ts │ │ │ ├── utils │ │ │ │ ├── index.ts │ │ │ │ ├── date.ts │ │ │ │ └── auth.ts │ │ │ ├── index.ts │ │ │ └── guards │ │ │ │ ├── index.ts │ │ │ │ ├── baseAuth.guard.ts │ │ │ │ ├── withAuthUser.guard.ts │ │ │ │ ├── emailConfirmRequired.guard.ts │ │ │ │ └── organizationRequired.guard.ts │ │ ├── organizations │ │ │ ├── entities │ │ │ │ └── organization.entity.ts │ │ │ ├── dto │ │ │ │ ├── index.ts │ │ │ │ ├── create-organization.dto.ts │ │ │ │ ├── add-member-after-invite.dto.ts │ │ │ │ ├── update-organization.dto.ts │ │ │ │ └── add-member-to-organization.dto.ts │ │ │ ├── organizations.module.ts │ │ │ ├── organizations.service.ts │ │ │ └── organizations.controller.ts │ │ ├── auth │ │ │ ├── dto │ │ │ │ ├── index.ts │ │ │ │ ├── update-user.dto.ts │ │ │ │ └── create-user.dto.ts │ │ │ ├── auth.module.ts │ │ │ ├── jwt.strategy.ts │ │ │ ├── auth.service.ts │ │ │ └── auth.controller.ts │ │ ├── environments │ │ │ ├── environment.ts │ │ │ └── environment.prod.ts │ │ ├── mail │ │ │ ├── templates │ │ │ │ ├── index.ts │ │ │ │ ├── userAcceptedInvite.ts │ │ │ │ ├── emailConfirmation.ts │ │ │ │ └── organizationInvitation.ts │ │ │ ├── mail.module.ts │ │ │ └── mail.service.ts │ │ ├── invitations │ │ │ ├── dto │ │ │ │ ├── index.ts │ │ │ │ ├── validate-token.dto.ts │ │ │ │ └── create-invitation.dto.ts │ │ │ ├── invitations.module.ts │ │ │ ├── invitations.service.ts │ │ │ └── invitations.controller.ts │ │ ├── users │ │ │ ├── users.controller.ts │ │ │ ├── users.module.ts │ │ │ └── users.service.ts │ │ ├── prisma │ │ │ ├── prisma.module.ts │ │ │ ├── prisma.service.ts │ │ │ └── seed.ts │ │ ├── app.module.ts │ │ └── main.ts │ ├── migrations │ │ ├── migration_lock.toml │ │ ├── 20221226101214_remove │ │ │ └── migration.sql │ │ └── 20220804150309_init │ │ │ └── migration.sql │ ├── tsconfig.json │ ├── tsconfig.spec.json │ ├── tsconfig.app.json │ ├── docker-compose.yml │ ├── .eslintrc.json │ ├── jest.config.ts │ ├── .env.example │ ├── project.json │ └── schema.prisma └── frontend │ ├── public │ └── .gitkeep │ ├── components │ ├── common │ │ ├── icons │ │ │ ├── index.tsx │ │ │ └── LogoIcon.tsx │ │ ├── index.tsx │ │ ├── AuthLoading.tsx │ │ ├── LogOutButton.tsx │ │ ├── AppLayout.tsx │ │ ├── LoginButton.tsx │ │ └── Link.tsx │ └── get-started │ │ ├── index.tsx │ │ └── GetStartedLayout.tsx │ ├── services │ ├── index.ts │ ├── invitation.tsx │ ├── organization.tsx │ └── auth.tsx │ ├── utils │ ├── auth │ │ ├── index.ts │ │ ├── withOrganizationRequired.tsx │ │ └── withEmailVerificationRequired.tsx │ ├── index.ts │ ├── theme.ts │ ├── createEmotionCache.ts │ └── useEffectDebugger.ts │ ├── index.d.ts │ ├── next-env.d.ts │ ├── .env.local.example │ ├── specs │ └── index.spec.tsx │ ├── jest.config.ts │ ├── next.config.js │ ├── tsconfig.spec.json │ ├── tsconfig.json │ ├── .eslintrc.json │ ├── pages │ ├── index.tsx │ ├── _app.tsx │ ├── get-started │ │ ├── invitations.tsx │ │ ├── organization.tsx │ │ ├── invitation-accepted.tsx │ │ └── email-confirm.tsx │ ├── _document.tsx │ └── styles.css │ ├── project.json │ └── context │ └── AuthContext.tsx ├── tools ├── generators │ └── .gitkeep └── tsconfig.tools.json ├── .prettierrc ├── babel.config.json ├── screenshot.png ├── screenshot2.png ├── libs └── shared │ ├── src │ ├── lib │ │ ├── constants │ │ │ ├── auth.ts │ │ │ ├── index.ts │ │ │ └── frontendRoutes.ts │ │ ├── types │ │ │ └── index.ts │ │ └── utils │ │ │ └── index.ts │ └── index.ts │ ├── .babelrc │ ├── tsconfig.lib.json │ ├── README.md │ ├── .eslintrc.json │ ├── jest.config.ts │ ├── tsconfig.json │ ├── tsconfig.spec.json │ └── project.json ├── .prettierignore ├── jest.preset.js ├── jest.config.ts ├── .vscode └── extensions.json ├── workspace.json ├── .editorconfig ├── tsconfig.base.json ├── .gitignore ├── .eslintrc.json ├── nx.json ├── LICENSE ├── docs ├── auth0.md └── nx.md ├── README.md ├── package.json ├── CODE_OF_CONDUCT.md └── migrations.json /apps/api/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tools/generators/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/frontend/public/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "babelrcRoots": ["*"] 3 | } 4 | -------------------------------------------------------------------------------- /apps/api/src/common/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth'; 2 | -------------------------------------------------------------------------------- /apps/api/src/common/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './reqUser.decorator'; 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DimiMikadze/fest/HEAD/screenshot.png -------------------------------------------------------------------------------- /screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DimiMikadze/fest/HEAD/screenshot2.png -------------------------------------------------------------------------------- /apps/api/src/common/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './date'; 2 | export * from './auth'; 3 | -------------------------------------------------------------------------------- /apps/api/src/organizations/entities/organization.entity.ts: -------------------------------------------------------------------------------- 1 | export class Organization {} 2 | -------------------------------------------------------------------------------- /libs/shared/src/lib/constants/auth.ts: -------------------------------------------------------------------------------- 1 | export const EMAIL_CONFIRMATION_CODE_LENGTH = 6; 2 | -------------------------------------------------------------------------------- /apps/frontend/components/common/icons/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as LogoIcon } from './LogoIcon'; 2 | -------------------------------------------------------------------------------- /libs/shared/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@nrwl/web/babel", { "useBuiltIns": "usage" }]] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | 3 | /dist 4 | /coverage 5 | -------------------------------------------------------------------------------- /apps/api/src/auth/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './update-user.dto'; 2 | export * from './create-user.dto'; 3 | -------------------------------------------------------------------------------- /apps/api/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | }; 4 | -------------------------------------------------------------------------------- /libs/shared/src/lib/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth'; 2 | export * from './frontendRoutes'; 3 | -------------------------------------------------------------------------------- /apps/api/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | }; 4 | -------------------------------------------------------------------------------- /apps/frontend/components/get-started/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as GetStartedLayout } from './GetStartedLayout'; 2 | -------------------------------------------------------------------------------- /jest.preset.js: -------------------------------------------------------------------------------- 1 | const nxPreset = require('@nrwl/jest/preset').default; 2 | 3 | module.exports = { ...nxPreset }; 4 | -------------------------------------------------------------------------------- /apps/api/src/mail/templates/index.ts: -------------------------------------------------------------------------------- 1 | export * from './emailConfirmation'; 2 | export * from './organizationInvitation'; 3 | -------------------------------------------------------------------------------- /apps/api/src/invitations/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-invitation.dto'; 2 | export * from './validate-token.dto'; 3 | -------------------------------------------------------------------------------- /libs/shared/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/types'; 2 | export * from './lib/constants'; 3 | export * from './lib/utils'; 4 | -------------------------------------------------------------------------------- /apps/frontend/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth'; 2 | export * from './organization'; 3 | export * from './invitation'; 4 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { getJestProjects } from '@nrwl/jest'; 2 | 3 | export default { 4 | projects: getJestProjects(), 5 | }; 6 | -------------------------------------------------------------------------------- /apps/frontend/utils/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './withEmailVerificationRequired'; 2 | export * from './withOrganizationRequired'; 3 | -------------------------------------------------------------------------------- /apps/api/src/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils'; 2 | export * from './constants'; 3 | export * from './guards'; 4 | export * from './decorators'; 5 | -------------------------------------------------------------------------------- /apps/frontend/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth'; 2 | export * from './useEffectDebugger'; 3 | export * from './theme'; 4 | export * from './createEmotionCache'; 5 | -------------------------------------------------------------------------------- /apps/api/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "nrwl.angular-console", 4 | "esbenp.prettier-vscode", 5 | "firsttris.vscode-jest-runner", 6 | "dbaeumer.vscode-eslint" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /apps/api/src/common/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './baseAuth.guard'; 2 | export * from './emailConfirmRequired.guard'; 3 | export * from './withAuthUser.guard'; 4 | export * from './organizationRequired.guard'; 5 | -------------------------------------------------------------------------------- /apps/api/src/invitations/dto/validate-token.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class ValidateTokenDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | token: string; 7 | } 8 | -------------------------------------------------------------------------------- /apps/frontend/index.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | declare module '*.svg' { 3 | const content: any; 4 | export const ReactComponent: any; 5 | export default content; 6 | } 7 | -------------------------------------------------------------------------------- /apps/api/src/common/guards/baseAuth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class BaseAuthGuard extends AuthGuard('jwt') {} 6 | -------------------------------------------------------------------------------- /apps/api/src/organizations/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-organization.dto'; 2 | export * from './update-organization.dto'; 3 | export * from './add-member-to-organization.dto'; 4 | export * from './add-member-after-invite.dto'; 5 | -------------------------------------------------------------------------------- /apps/api/src/mail/mail.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MailService } from './mail.service'; 3 | 4 | @Module({ 5 | providers: [MailService], 6 | exports: [MailService], 7 | }) 8 | export class MailModule {} 9 | -------------------------------------------------------------------------------- /apps/frontend/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /workspace.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/nx/schemas/workspace-schema.json", 3 | "version": 2, 4 | "projects": { 5 | "api": "apps/api", 6 | "frontend": "apps/frontend", 7 | "shared": "libs/shared" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /apps/api/src/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | import { UsersService } from './users.service'; 3 | 4 | @Controller('users') 5 | export class UsersController { 6 | constructor(private readonly usersService: UsersService) {} 7 | } 8 | -------------------------------------------------------------------------------- /apps/frontend/.env.local.example: -------------------------------------------------------------------------------- 1 | # Auth0 2 | NEXT_PUBLIC_AUTH0_DOMAIN=ADD_YOUR_AUTH0_DOMAIN 3 | NEXT_PUBLIC_AUTH0_CLIENT_ID=ADD_YOUR_AUTH0_CLIENT 4 | NEXT_PUBLIC_AUTH0_API_AUDIENCE=ADD_YOUR_AUTH0_AUDIENCE 5 | 6 | # External API URL 7 | NEXT_PUBLIC_API_URL=http://localhost:3333 8 | -------------------------------------------------------------------------------- /apps/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.app.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /apps/api/src/prisma/prisma.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { PrismaService } from './prisma.service'; 3 | 4 | @Global() 5 | @Module({ 6 | providers: [PrismaService], 7 | exports: [PrismaService], 8 | }) 9 | export class PrismaModule {} 10 | -------------------------------------------------------------------------------- /libs/shared/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "types": [] 7 | }, 8 | "include": ["**/*.ts"], 9 | "exclude": ["jest.config.ts", "**/*.spec.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/api/src/organizations/dto/create-organization.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class CreateOrganizationDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | name: string; 7 | 8 | @IsString() 9 | @IsNotEmpty() 10 | userId: string; 11 | } 12 | -------------------------------------------------------------------------------- /apps/api/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/api/src/common/utils/date.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Add hours to the specific date. 3 | */ 4 | export function addHours(numOfHours: number, date = new Date()): Date { 5 | const dateCopy = new Date(date.getTime()); 6 | dateCopy.setTime(dateCopy.getTime() + numOfHours * 60 * 60 * 1000); 7 | return dateCopy; 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /apps/frontend/components/common/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as Link } from './Link'; 2 | export { default as AuthLoading } from './AuthLoading'; 3 | export { default as LoginButton } from './LoginButton'; 4 | export { default as LogOutButton } from './LogOutButton'; 5 | export { default as AppLayout } from './AppLayout'; 6 | -------------------------------------------------------------------------------- /libs/shared/src/lib/types/index.ts: -------------------------------------------------------------------------------- 1 | export enum AuthProviders { 2 | GOOGLE = 'google', 3 | AUTH0 = 'auth0', 4 | } 5 | 6 | export interface InvitationTokenDecoded { 7 | email: string; 8 | organizationId: string; 9 | organizationName: string; 10 | inviterId: string; 11 | inviterEmail: string; 12 | } 13 | -------------------------------------------------------------------------------- /apps/api/src/common/decorators/reqUser.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const ReqUser = createParamDecorator( 4 | (data: unknown, ctx: ExecutionContext) => { 5 | const request = ctx.switchToHttp().getRequest(); 6 | return request.user; 7 | } 8 | ); 9 | -------------------------------------------------------------------------------- /libs/shared/README.md: -------------------------------------------------------------------------------- 1 | # shared 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test shared` to execute the unit tests via [Jest](https://jestjs.io). 8 | 9 | ## Running lint 10 | 11 | Run `nx lint shared` to execute the lint via [ESLint](https://eslint.org/). 12 | -------------------------------------------------------------------------------- /tools/tsconfig.tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/out-tsc/tools", 5 | "rootDir": ".", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "types": ["node"], 9 | "importHelpers": false 10 | }, 11 | "include": ["**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /apps/frontend/components/common/icons/LogoIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from '../'; 3 | 4 | interface LogoIconProps { 5 | color?: 'black' | 'white'; 6 | } 7 | 8 | const LogoIcon = ({ color = 'white' }: LogoIconProps) => { 9 | return Fest; 10 | }; 11 | 12 | export default LogoIcon; 13 | -------------------------------------------------------------------------------- /apps/api/migrations/20221226101214_remove/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `Room` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- DropForeignKey 8 | ALTER TABLE "Room" DROP CONSTRAINT "Room_organizationId_fkey"; 9 | 10 | -- DropTable 11 | DROP TABLE "Room"; 12 | -------------------------------------------------------------------------------- /apps/api/src/organizations/dto/add-member-after-invite.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty } from 'class-validator'; 2 | import { AddMemberToOrganizationDto } from './add-member-to-organization.dto'; 3 | 4 | export class AddMemberAfterInviteDto extends AddMemberToOrganizationDto { 5 | @IsEmail() 6 | @IsNotEmpty() 7 | inviterEmail: string; 8 | } 9 | -------------------------------------------------------------------------------- /apps/frontend/components/common/AuthLoading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { CircularProgress, Stack } from '@mui/material'; 3 | 4 | const AuthLoading = () => { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | }; 11 | 12 | export default AuthLoading; 13 | -------------------------------------------------------------------------------- /apps/api/src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UsersService } from './users.service'; 3 | import { UsersController } from './users.controller'; 4 | 5 | @Module({ 6 | controllers: [UsersController], 7 | providers: [UsersService], 8 | exports: [UsersService], 9 | }) 10 | export class UsersModule {} 11 | -------------------------------------------------------------------------------- /apps/api/src/mail/templates/userAcceptedInvite.ts: -------------------------------------------------------------------------------- 1 | interface userAcceptedInviteProps { 2 | fullName?: string; 3 | email: string; 4 | } 5 | 6 | export const userAcceptedInvite = ({ 7 | fullName, 8 | email, 9 | }: userAcceptedInviteProps) => ` 10 |
11 |

${fullName || email} has accepted your invitation

12 |
13 | `; 14 | -------------------------------------------------------------------------------- /apps/frontend/specs/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | 4 | import Index from '../pages/index'; 5 | 6 | describe('Index', () => { 7 | it('should render successfully', () => { 8 | const { baseElement } = render(); 9 | expect(baseElement).toBeTruthy(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /libs/shared/src/lib/constants/frontendRoutes.ts: -------------------------------------------------------------------------------- 1 | export const Frontend_Routes = { 2 | HOME: '/', 3 | GET_STARTED_EMAIL_CONFIRM: '/get-started/email-confirm', 4 | GET_STARTED_ORGANIZATION: '/get-started/organization', 5 | GET_STARTED_INVITATIONS: '/get-started/invitations', 6 | GET_STARTED_INVITATION_ACCEPTED: '/get-started/invitation-accepted', 7 | }; 8 | -------------------------------------------------------------------------------- /apps/api/src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PrismaService } from '../prisma/prisma.service'; 3 | 4 | @Injectable() 5 | export class UsersService { 6 | constructor(private prisma: PrismaService) {} 7 | 8 | findOneById(id: string) { 9 | return this.prisma.user.findUnique({ where: { id } }); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apps/api/src/invitations/dto/create-invitation.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class CreateInvitationDto { 4 | @IsEmail() 5 | @IsNotEmpty() 6 | email: string; 7 | 8 | @IsString() 9 | @IsNotEmpty() 10 | inviterId: string; 11 | 12 | @IsString() 13 | @IsNotEmpty() 14 | organizationId: string; 15 | } 16 | -------------------------------------------------------------------------------- /apps/api/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["node"], 7 | "emitDecoratorMetadata": true, 8 | "target": "es2015" 9 | }, 10 | "exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts"], 11 | "include": ["**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /apps/api/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres 4 | restart: always 5 | environment: 6 | POSTGRES_DB: ${POSTGRES_DB} 7 | POSTGRES_USER: ${POSTGRES_USER} 8 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 9 | ports: 10 | - '5432:5432' 11 | volumes: 12 | - postgres:/var/lib/postgresql/data 13 | 14 | volumes: 15 | postgres: 16 | -------------------------------------------------------------------------------- /apps/api/src/organizations/dto/update-organization.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types'; 2 | import { IsOptional, IsString } from 'class-validator'; 3 | import { CreateOrganizationDto } from './create-organization.dto'; 4 | 5 | export class UpdateOrganizationDto extends PartialType(CreateOrganizationDto) { 6 | @IsOptional() 7 | @IsString() 8 | logo?: string; 9 | } 10 | -------------------------------------------------------------------------------- /apps/api/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /libs/shared/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /apps/api/src/common/guards/withAuthUser.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | /** 5 | * Makes authUser accessible within the controller method 6 | */ 7 | @Injectable() 8 | export class WithAuthUserGuard extends AuthGuard('jwt') { 9 | handleRequest(err: any, user: any) { 10 | if (user) return user; 11 | return null; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/frontend/utils/theme.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from '@mui/material/styles'; 2 | import { red } from '@mui/material/colors'; 3 | 4 | // Create a theme instance. 5 | export const theme = createTheme({ 6 | palette: { 7 | primary: { 8 | main: '#0065FF', 9 | }, 10 | secondary: { 11 | main: '#19857b', 12 | }, 13 | error: { 14 | main: red.A400, 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /libs/shared/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'shared', 4 | preset: '../../jest.preset.js', 5 | globals: { 6 | 'ts-jest': { 7 | tsconfig: '/tsconfig.spec.json', 8 | }, 9 | }, 10 | transform: { 11 | '^.+\\.[tj]sx?$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 14 | coverageDirectory: '../../coverage/libs/shared', 15 | }; 16 | -------------------------------------------------------------------------------- /apps/frontend/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'frontend', 4 | preset: '../../jest.preset.js', 5 | transform: { 6 | '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nrwl/react/plugins/jest', 7 | '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nrwl/next/babel'] }], 8 | }, 9 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 10 | coverageDirectory: '../../coverage/apps/frontend', 11 | }; 12 | -------------------------------------------------------------------------------- /apps/api/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'api', 4 | preset: '../../jest.preset.js', 5 | globals: { 6 | 'ts-jest': { 7 | tsconfig: '/tsconfig.spec.json', 8 | }, 9 | }, 10 | testEnvironment: 'node', 11 | transform: { 12 | '^.+\\.[tj]s$': 'ts-jest', 13 | }, 14 | moduleFileExtensions: ['ts', 'js', 'html'], 15 | coverageDirectory: '../../coverage/apps/api', 16 | }; 17 | -------------------------------------------------------------------------------- /apps/api/src/organizations/dto/add-member-to-organization.dto.ts: -------------------------------------------------------------------------------- 1 | import { OrganizationRole } from '@prisma/client'; 2 | import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; 3 | 4 | export class AddMemberToOrganizationDto { 5 | @IsString() 6 | @IsNotEmpty() 7 | userId: string; 8 | 9 | @IsString() 10 | @IsNotEmpty() 11 | organizationId: string; 12 | 13 | @IsString() 14 | @IsOptional() 15 | OrganizationRole: OrganizationRole; 16 | } 17 | -------------------------------------------------------------------------------- /libs/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ], 13 | "compilerOptions": { 14 | "forceConsistentCasingInFileNames": true, 15 | "strict": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/frontend/next.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const withNx = require('@nrwl/next/plugins/with-nx'); 3 | 4 | /** 5 | * @type {import('@nrwl/next/plugins/with-nx').WithNxOptions} 6 | **/ 7 | const nextConfig = { 8 | nx: { 9 | // Set this to true if you would like to to use SVGR 10 | // See: https://github.com/gregberge/svgr 11 | svgr: false, 12 | }, 13 | }; 14 | 15 | module.exports = withNx(nextConfig); 16 | -------------------------------------------------------------------------------- /libs/shared/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "jest.config.ts", 10 | "**/*.test.ts", 11 | "**/*.spec.ts", 12 | "**/*.test.tsx", 13 | "**/*.spec.tsx", 14 | "**/*.test.js", 15 | "**/*.spec.js", 16 | "**/*.test.jsx", 17 | "**/*.spec.jsx", 18 | "**/*.d.ts" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /apps/api/src/prisma/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common'; 2 | import { PrismaClient } from '@prisma/client'; 3 | 4 | @Injectable() 5 | export class PrismaService extends PrismaClient implements OnModuleInit { 6 | async onModuleInit() { 7 | await this.$connect(); 8 | } 9 | 10 | async enableShutdownHooks(app: INestApplication) { 11 | this.$on('beforeExit', async () => { 12 | await app.close(); 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/api/src/common/guards/emailConfirmRequired.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class EmailConfirmRequiredGuard extends AuthGuard('jwt') { 6 | handleRequest(err: any, user: any) { 7 | if (!user.emailVerified) { 8 | throw new UnauthorizedException( 9 | 'Please confirm your email before continue.' 10 | ); 11 | } 12 | return user; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"], 7 | "jsx": "react" 8 | }, 9 | "include": [ 10 | "jest.config.ts", 11 | "**/*.test.ts", 12 | "**/*.spec.ts", 13 | "**/*.test.tsx", 14 | "**/*.spec.tsx", 15 | "**/*.test.js", 16 | "**/*.spec.js", 17 | "**/*.test.jsx", 18 | "**/*.spec.jsx", 19 | "**/*.d.ts" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /apps/api/src/common/guards/organizationRequired.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { EmailConfirmRequiredGuard } from './emailConfirmRequired.guard'; 3 | 4 | @Injectable() 5 | export class OrganizationRequiredGuard extends EmailConfirmRequiredGuard { 6 | handleRequest(err: any, user: any) { 7 | if (!user?.currentOrganization) { 8 | throw new UnauthorizedException( 9 | 'Please create a new organization or join the existing one' 10 | ); 11 | } 12 | return user; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/api/src/organizations/organizations.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { OrganizationsService } from './organizations.service'; 3 | import { OrganizationsController } from './organizations.controller'; 4 | import { AuthService } from '../auth/auth.service'; 5 | import { MailModule } from '../mail/mail.module'; 6 | 7 | @Module({ 8 | imports: [MailModule], 9 | controllers: [OrganizationsController], 10 | providers: [OrganizationsService, AuthService], 11 | exports: [OrganizationsService], 12 | }) 13 | export class OrganizationsModule {} 14 | -------------------------------------------------------------------------------- /apps/frontend/components/common/LogOutButton.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth0 } from '@auth0/auth0-react'; 2 | import Button from '@mui/material/Button'; 3 | import { useAuth } from '../../context/AuthContext'; 4 | 5 | const LogoutButton = () => { 6 | const { logout: logoutAuth0 } = useAuth0(); 7 | const { setIsAuthLoading } = useAuth(); 8 | 9 | const logout = async () => { 10 | setIsAuthLoading(true); 11 | return logoutAuth0({ 12 | returnTo: window.location.origin, 13 | }); 14 | }; 15 | 16 | return ; 17 | }; 18 | 19 | export default LogoutButton; 20 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "jsx": "preserve", 5 | "allowJs": true, 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "strict": false, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "incremental": true, 14 | "types": ["jest", "node"] 15 | }, 16 | "include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "next-env.d.ts"], 17 | "exclude": ["node_modules", "jest.config.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "importHelpers": true, 11 | "target": "es2015", 12 | "module": "esnext", 13 | "lib": ["es2017", "dom"], 14 | "skipLibCheck": true, 15 | "skipDefaultLibCheck": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "@fest/shared": ["libs/shared/src/index.ts"] 19 | } 20 | }, 21 | "exclude": ["node_modules", "tmp"] 22 | } 23 | -------------------------------------------------------------------------------- /apps/api/src/mail/templates/emailConfirmation.ts: -------------------------------------------------------------------------------- 1 | interface emailConfirmationProps { 2 | code: string; 3 | link: string; 4 | } 5 | 6 | export const emailConfirmation = ({ code, link }: emailConfirmationProps) => ` 7 |
8 |

You're almost there

9 |

To confirm your email, simply go back to the browser window where you started creating your Fest account and enter this code

10 | 11 | ${code} 12 | 13 |

Or make it even quicker by clicking the button below

14 | 15 | Confirm your email 16 | 17 |

If you didn't create an account in Fest, please ignore this message

18 |
19 | `; 20 | -------------------------------------------------------------------------------- /apps/api/src/mail/templates/organizationInvitation.ts: -------------------------------------------------------------------------------- 1 | interface organizationInvitationProps { 2 | inviterName?: string; 3 | inviterEmail: string; 4 | organizationName: string; 5 | link: string; 6 | } 7 | 8 | export const organizationInvitation = ({ 9 | inviterName, 10 | inviterEmail, 11 | organizationName, 12 | link, 13 | }: organizationInvitationProps) => ` 14 |
15 |

Join your team on Fest

16 |

17 | 18 | ${ 19 | inviterName && inviterName 20 | }(${inviterEmail}) has invited you to use Fest with them, in a team called ${organizationName} 21 | 22 | JOIN NOW 23 | 24 |
25 | `; 26 | -------------------------------------------------------------------------------- /libs/shared/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 3 | "sourceRoot": "libs/shared/src", 4 | "projectType": "library", 5 | "targets": { 6 | "lint": { 7 | "executor": "@nrwl/linter:eslint", 8 | "outputs": ["{options.outputFile}"], 9 | "options": { 10 | "lintFilePatterns": ["libs/shared/**/*.ts"] 11 | } 12 | }, 13 | "test": { 14 | "executor": "@nrwl/jest:jest", 15 | "outputs": ["coverage/libs/shared"], 16 | "options": { 17 | "jestConfig": "libs/shared/jest.config.ts", 18 | "passWithNoTests": true 19 | } 20 | } 21 | }, 22 | "tags": [] 23 | } 24 | -------------------------------------------------------------------------------- /apps/api/.env.example: -------------------------------------------------------------------------------- 1 | # PostgreSQL database URL and credentials 2 | DATABASE_URL=postgresql://username:password@localhost:5432/postgres?schema=public 3 | 4 | POSTGRES_DB=postgres 5 | POSTGRES_USER=username 6 | POSTGRES_PASSWORD=password 7 | 8 | # Auth0 9 | AUTH0_DOMAIN=ADD_AUTH0_DOMAIN 10 | AUTH0_AUDIENCE=ADD_AUTH0_AUDIENCE 11 | 12 | # Email Confirmation Token Secret 13 | EMAIL_CONFIRM_TOKEN_SECRET=RANDOM_TOKEN 14 | 15 | # Organization invitation token secret 16 | ORGANIZATION_INVITATION_TOKEN_SECRET=RANDOM_TOKEN 17 | 18 | # Front-end URL 19 | FRONT_END_URL=http://localhost:3000 20 | 21 | # Postmark 22 | POSTMARK_KEY=ADD_YOUR_POSTMARK_KEY 23 | POSTMARK_SENDER=no-reply@fest.dev 24 | -------------------------------------------------------------------------------- /apps/api/src/invitations/invitations.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthModule } from '../auth/auth.module'; 3 | import { MailModule } from '../mail/mail.module'; 4 | import { OrganizationsModule } from '../organizations/organizations.module'; 5 | import { UsersModule } from '../users/users.module'; 6 | import { InvitationsController } from './invitations.controller'; 7 | import { InvitationsService } from './invitations.service'; 8 | 9 | @Module({ 10 | imports: [MailModule, UsersModule, AuthModule, OrganizationsModule], 11 | controllers: [InvitationsController], 12 | providers: [InvitationsService], 13 | }) 14 | export class InvitationsModule {} 15 | -------------------------------------------------------------------------------- /apps/api/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | 4 | import { PrismaModule } from './prisma/prisma.module'; 5 | import { UsersModule } from './users/users.module'; 6 | import { AuthModule } from './auth/auth.module'; 7 | import { OrganizationsModule } from './organizations/organizations.module'; 8 | import { InvitationsModule } from './invitations/invitations.module'; 9 | 10 | @Module({ 11 | imports: [ 12 | ConfigModule.forRoot({ isGlobal: true }), 13 | PrismaModule, 14 | UsersModule, 15 | AuthModule, 16 | OrganizationsModule, 17 | InvitationsModule, 18 | ], 19 | }) 20 | export class AppModule {} 21 | -------------------------------------------------------------------------------- /apps/api/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PassportModule } from '@nestjs/passport'; 3 | import { UsersModule } from '../users/users.module'; 4 | import { JwtStrategy } from './jwt.strategy'; 5 | import { AuthController } from './auth.controller'; 6 | import { AuthService } from './auth.service'; 7 | import { MailModule } from '../mail/mail.module'; 8 | 9 | @Module({ 10 | imports: [ 11 | PassportModule.register({ defaultStrategy: 'jwt' }), 12 | UsersModule, 13 | MailModule, 14 | ], 15 | providers: [JwtStrategy, AuthService], 16 | exports: [PassportModule, AuthService], 17 | controllers: [AuthController], 18 | }) 19 | export class AuthModule {} 20 | -------------------------------------------------------------------------------- /apps/api/src/auth/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | import { PartialType } from '@nestjs/mapped-types'; 3 | import { IsBoolean, IsDate, IsOptional, IsString } from 'class-validator'; 4 | import { CreateUserDto } from './create-user.dto'; 5 | 6 | export class UpdateUserDto extends PartialType(CreateUserDto) { 7 | @IsString() 8 | @IsOptional() 9 | emailVerificationToken?: string; 10 | 11 | @IsString() 12 | @IsOptional() 13 | emailVerificationCode?: string; 14 | 15 | @IsDate() 16 | @IsOptional() 17 | emailVerificationCodeExpires?: Date; 18 | 19 | @IsBoolean() 20 | @IsOptional() 21 | @Transform(({ value }) => value === 'true') 22 | emailVerificationLinkSent?: boolean; 23 | } 24 | -------------------------------------------------------------------------------- /apps/frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:@nrwl/nx/react-typescript", 4 | "next", 5 | "next/core-web-vitals", 6 | "../../.eslintrc.json" 7 | ], 8 | "ignorePatterns": ["!**/*"], 9 | "overrides": [ 10 | { 11 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 12 | "rules": { 13 | "@next/next/no-html-link-for-pages": ["error", "apps/frontend/pages"] 14 | } 15 | }, 16 | { 17 | "files": ["*.ts", "*.tsx"], 18 | "rules": {} 19 | }, 20 | { 21 | "files": ["*.js", "*.jsx"], 22 | "rules": {} 23 | } 24 | ], 25 | "rules": { 26 | "@next/next/no-html-link-for-pages": "off" 27 | }, 28 | "env": { 29 | "jest": true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /apps/api/src/common/constants/auth.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Expiration period of the code for the email confirmation. 3 | */ 4 | export const EMAIL_CONFIRMATION_CODE_EXPIRY = 1; // in hours 5 | 6 | /** 7 | * Expiration period of the token for the email verification. 8 | */ 9 | export const EMAIL_CONFIRMATION_TOKEN_EXPIRY = '1d'; // "10h", "7d" or number in milliseconds 10 | 11 | /** 12 | * Expiration period of the token for team invitations 13 | */ 14 | export const ORGANIZATION_INVITATION_TOKEN_EXPIRY = '30d'; 15 | 16 | /** 17 | * Relations order for the users to correctly transform the user's record data. 18 | */ 19 | export const USER_RELATIONS = { 20 | organizations: { 21 | include: { 22 | organization: true, 23 | }, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | 41 | # dotenv environment variables file 42 | .env 43 | .env.local 44 | .env.production 45 | -------------------------------------------------------------------------------- /apps/frontend/utils/createEmotionCache.ts: -------------------------------------------------------------------------------- 1 | import createCache from '@emotion/cache'; 2 | 3 | const isBrowser = typeof document !== 'undefined'; 4 | 5 | // On the client side, Create a meta tag at the top of the and set it as insertionPoint. 6 | // This assures that MUI styles are loaded first. 7 | // It allows developers to easily override MUI styles with other styling solutions, like CSS modules. 8 | export function createEmotionCache() { 9 | let insertionPoint; 10 | 11 | if (isBrowser) { 12 | const emotionInsertionPoint = document.querySelector( 13 | 'meta[name="emotion-insertion-point"]' 14 | ); 15 | insertionPoint = emotionInsertionPoint ?? undefined; 16 | } 17 | 18 | return createCache({ key: 'mui-style', insertionPoint }); 19 | } 20 | -------------------------------------------------------------------------------- /apps/api/src/auth/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | import { 3 | IsBoolean, 4 | IsEmail, 5 | IsNotEmpty, 6 | IsOptional, 7 | IsString, 8 | } from 'class-validator'; 9 | 10 | export class CreateUserDto { 11 | @IsString() 12 | @IsOptional() 13 | auth0Id?: string; 14 | 15 | @IsString() 16 | @IsOptional() 17 | googleId?: string; 18 | 19 | @IsEmail() 20 | @IsNotEmpty() 21 | email: string; 22 | 23 | @IsBoolean() 24 | @IsOptional() 25 | @Transform(({ value }) => value === 'true') 26 | emailVerified?: boolean; 27 | 28 | @IsString() 29 | @IsOptional() 30 | fullName?: string; 31 | 32 | @IsString() 33 | @IsOptional() 34 | avatar?: string; 35 | 36 | @IsOptional() 37 | @IsString() 38 | currentOrganizationId?: string; 39 | } 40 | -------------------------------------------------------------------------------- /libs/shared/src/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { AuthProviders } from '../types'; 2 | 3 | export const findAuthProviderFromAuth0Id = (id: string): AuthProviders => { 4 | if (id.startsWith(AuthProviders.GOOGLE)) { 5 | return AuthProviders.GOOGLE; 6 | } 7 | 8 | return AuthProviders.AUTH0; 9 | }; 10 | 11 | export const isObjectEmpty = (obj: { [key: string]: unknown }) => { 12 | for (const i in obj) return false; 13 | return true; 14 | }; 15 | 16 | export const isValidEmail = (email: string) => { 17 | const regex = 18 | /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; 19 | return regex.test(email); 20 | }; 21 | 22 | export const isNotEmptyArray = (array: unknown[]) => { 23 | return array && array.length; 24 | }; 25 | -------------------------------------------------------------------------------- /apps/frontend/services/invitation.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@tanstack/react-query'; 2 | import axios from 'axios'; 3 | 4 | export const useCreateInvitation = (): any => { 5 | interface createInvitationProps { 6 | email: string; 7 | inviterId: string; 8 | organizationId: string; 9 | } 10 | 11 | const createInvitation = async (fields: createInvitationProps) => { 12 | const { data } = await axios.post('/invitations/create', fields); 13 | return data; 14 | }; 15 | return useMutation(createInvitation); 16 | }; 17 | 18 | export const useValidateToken = (): any => { 19 | interface validateTokenProps { 20 | token: string; 21 | } 22 | 23 | const validateToken = async (fields: validateTokenProps) => { 24 | const { data } = await axios.post('/invitations/validate-token', fields); 25 | return data; 26 | }; 27 | return useMutation(validateToken); 28 | }; 29 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/*"], 4 | "plugins": ["@nrwl/nx"], 5 | "overrides": [ 6 | { 7 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 8 | "rules": { 9 | "@nrwl/nx/enforce-module-boundaries": [ 10 | "error", 11 | { 12 | "enforceBuildableLibDependency": true, 13 | "allow": [], 14 | "depConstraints": [ 15 | { 16 | "sourceTag": "*", 17 | "onlyDependOnLibsWithTags": ["*"] 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | }, 24 | { 25 | "files": ["*.ts", "*.tsx"], 26 | "extends": ["plugin:@nrwl/nx/typescript"], 27 | "rules": {} 28 | }, 29 | { 30 | "files": ["*.js", "*.jsx"], 31 | "extends": ["plugin:@nrwl/nx/javascript"], 32 | "rules": {} 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /apps/api/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { ValidationPipe } from '@nestjs/common'; 4 | 5 | import { AppModule } from './app.module'; 6 | import { PrismaService } from './prisma/prisma.service'; 7 | 8 | async function bootstrap() { 9 | const app = await NestFactory.create(AppModule); 10 | 11 | // Cors 12 | app.enableCors(); 13 | 14 | // Validation Pipes 15 | app.useGlobalPipes(new ValidationPipe()); 16 | 17 | // Prisma - Issues with enableShutdownHooks 18 | // https://docs.nestjs.com/recipes/prisma#issues-with-enableshutdownhooks 19 | const prismaService = app.get(PrismaService); 20 | await prismaService.enableShutdownHooks(app); 21 | 22 | const port = process.env.PORT || 3333; 23 | await app.listen(port); 24 | Logger.log(`🚀 Application is running on: http://localhost:${port}`); 25 | } 26 | 27 | bootstrap(); 28 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/nx/schemas/nx-schema.json", 3 | "npmScope": "fest", 4 | "affected": { 5 | "defaultBase": "main" 6 | }, 7 | "implicitDependencies": { 8 | "package.json": { 9 | "dependencies": "*", 10 | "devDependencies": "*" 11 | }, 12 | ".eslintrc.json": "*" 13 | }, 14 | "tasksRunnerOptions": { 15 | "default": { 16 | "runner": "nx/tasks-runners/default", 17 | "options": { 18 | "cacheableOperations": ["build", "lint", "test", "e2e"] 19 | } 20 | } 21 | }, 22 | "targetDefaults": { 23 | "build": { 24 | "dependsOn": ["^build"] 25 | } 26 | }, 27 | "generators": { 28 | "@nrwl/react": { 29 | "application": { 30 | "babel": true 31 | } 32 | }, 33 | "@nrwl/next": { 34 | "application": { 35 | "style": "css", 36 | "linter": "eslint" 37 | } 38 | } 39 | }, 40 | "defaultProject": "api" 41 | } 42 | -------------------------------------------------------------------------------- /apps/frontend/components/common/AppLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from '@mui/material'; 2 | import { Divider } from '@mui/material'; 3 | import React, { ReactNode } from 'react'; 4 | import { LogOutButton, Link } from '.'; 5 | import { useAuth } from '../../context/AuthContext'; 6 | 7 | interface AppLayoutPros { 8 | children: ReactNode; 9 | } 10 | 11 | const AppLayout = ({ children }: AppLayoutPros) => { 12 | const { authUser } = useAuth(); 13 | 14 | return ( 15 | <> 16 | 23 | 24 | {authUser.currentOrganization.name} 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {children} 33 | 34 | ); 35 | }; 36 | 37 | export default AppLayout; 38 | -------------------------------------------------------------------------------- /apps/frontend/services/organization.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@tanstack/react-query'; 2 | import axios from 'axios'; 3 | 4 | export const useCreateOrganization = (): any => { 5 | interface createOrganizationProps { 6 | name: string; 7 | userId: string; 8 | } 9 | 10 | const createOrganization = async (fields: createOrganizationProps) => { 11 | const { data } = await axios.post('/organizations/create', fields); 12 | return data; 13 | }; 14 | 15 | return useMutation(createOrganization); 16 | }; 17 | 18 | export const useAddMemberAfterInvite = (): any => { 19 | interface addMemberAfterInviteProps { 20 | userId: string; 21 | organizationId: string; 22 | } 23 | 24 | const addMemberAfterInvite = async (fields: addMemberAfterInviteProps) => { 25 | const { data } = await axios.post( 26 | '/organizations/add-member-after-invite', 27 | fields 28 | ); 29 | return data; 30 | }; 31 | 32 | return useMutation(addMemberAfterInvite); 33 | }; 34 | -------------------------------------------------------------------------------- /apps/frontend/utils/useEffectDebugger.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | const usePrevious = (value, initialValue) => { 4 | const ref = useRef(initialValue); 5 | useEffect(() => { 6 | ref.current = value; 7 | }); 8 | return ref.current; 9 | }; 10 | 11 | export const useEffectDebugger = ( 12 | effectHook, 13 | dependencies, 14 | dependencyNames = [] 15 | ) => { 16 | const previousDeps = usePrevious(dependencies, []); 17 | 18 | const changedDeps = dependencies.reduce((accum, dependency, index) => { 19 | if (dependency !== previousDeps[index]) { 20 | const keyName = dependencyNames[index] || index; 21 | return { 22 | ...accum, 23 | [keyName]: { 24 | before: previousDeps[index], 25 | after: dependency, 26 | }, 27 | }; 28 | } 29 | 30 | return accum; 31 | }, {}); 32 | 33 | if (Object.keys(changedDeps).length) { 34 | console.log('[use-effect-debugger] ', changedDeps); 35 | } 36 | 37 | useEffect(effectHook, dependencies); 38 | }; 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Dimi Mikadze. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /apps/frontend/utils/auth/withOrganizationRequired.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType, useEffect, FC } from 'react'; 2 | import Router from 'next/router'; 3 | import { useAuth } from '../../context/AuthContext'; 4 | import { withEmailVerificationRequired } from './withEmailVerificationRequired'; 5 | import { Frontend_Routes } from '@fest/shared'; 6 | 7 | /** 8 | * A HOC requiring join or create organization for a user. 9 | */ 10 | export const withOrganizationRequired =

( 11 | Component: ComponentType

12 | ): FC

=> { 13 | return withEmailVerificationRequired(function WithOrganizationRequired( 14 | props: P 15 | ): JSX.Element { 16 | const { authUser, isAuthLoading } = useAuth(); 17 | 18 | useEffect(() => { 19 | if (isAuthLoading) return; 20 | if (!authUser?.currentOrganization) { 21 | Router.push(Frontend_Routes.GET_STARTED_ORGANIZATION); 22 | return; 23 | } 24 | }, [isAuthLoading, authUser?.currentOrganization]); 25 | 26 | return authUser?.currentOrganization ? : null; 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /apps/api/src/mail/mail.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | InternalServerErrorException, 4 | Logger, 5 | } from '@nestjs/common'; 6 | import { ConfigService } from '@nestjs/config'; 7 | import * as postmark from 'postmark'; 8 | 9 | @Injectable() 10 | export class MailService { 11 | private readonly logger = new Logger(MailService.name); 12 | mailClient: any; 13 | 14 | constructor(private config: ConfigService) { 15 | this.mailClient = new postmark.ServerClient( 16 | this.config.get('POSTMARK_KEY') 17 | ); 18 | } 19 | 20 | async send({ to, subject, body }) { 21 | try { 22 | this.mailClient.sendEmail({ 23 | From: this.config.get('POSTMARK_SENDER'), 24 | To: to, 25 | Subject: subject, 26 | HtmlBody: body, 27 | MessageStream: 'outbound', 28 | }); 29 | this.logger.log(`Email successfully send to the ${to} email address`); 30 | } catch (error) { 31 | this.logger.error( 32 | `Sending email to the ${to} email address failed`, 33 | error 34 | ); 35 | throw new InternalServerErrorException(error); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/frontend/components/get-started/GetStartedLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from '@mui/material'; 2 | import { Box } from '@mui/system'; 3 | import { Divider } from '@mui/material'; 4 | import React, { ReactNode } from 'react'; 5 | import { LogOutButton } from '../common'; 6 | import { LogoIcon } from '../common/icons'; 7 | 8 | interface GetStartedLayoutPros { 9 | children: ReactNode; 10 | hideLogOutButton?: boolean; 11 | } 12 | 13 | const GetStartedLayout = ({ 14 | children, 15 | hideLogOutButton = false, 16 | }: GetStartedLayoutPros) => { 17 | return ( 18 | <> 19 | 26 | 27 | 28 | 29 | 30 | {!hideLogOutButton && } 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {children} 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default GetStartedLayout; 45 | -------------------------------------------------------------------------------- /apps/frontend/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next'; 2 | import { AppLayout } from '../components/common'; 3 | import { useAuth } from '../context/AuthContext'; 4 | import { withOrganizationRequired } from '../utils'; 5 | 6 | const Home: NextPage = () => { 7 | const { authUser } = useAuth(); 8 | 9 | if (!authUser) return

Not authenticated..
; 10 | 11 | return ( 12 | 13 |

User profile

14 | 15 |
    16 |
  • 17 | FullName: {authUser.fullName} 18 |
  • 19 |
  • 20 | Email: {authUser.email} 21 |
  • 22 |
23 |

Current Organization

24 |
    25 |
  • 26 | Name: {authUser.currentOrganization.name} | Role:{' '} 27 | {authUser.currentOrganization.role} 28 |
  • 29 |
30 |

All Organizations

31 |
    32 | {authUser.organizations.map((org) => ( 33 |
  • 34 | Name: {org.name} | Role: {org.role} 35 |
  • 36 | ))} 37 |
38 |
39 | ); 40 | }; 41 | 42 | export default withOrganizationRequired(Home); 43 | -------------------------------------------------------------------------------- /apps/api/src/invitations/invitations.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PrismaService } from '../prisma/prisma.service'; 3 | import { CreateInvitationDto } from './dto'; 4 | 5 | @Injectable() 6 | export class InvitationsService { 7 | constructor(private prisma: PrismaService) {} 8 | 9 | create(createInvitationDto: CreateInvitationDto, token: string) { 10 | const { email, inviterId, organizationId } = createInvitationDto; 11 | const invitation = this.prisma.invitation.create({ 12 | data: { 13 | token, 14 | email, 15 | organization: { 16 | connect: { 17 | id: organizationId, 18 | }, 19 | }, 20 | inviter: { 21 | connect: { 22 | id: inviterId, 23 | }, 24 | }, 25 | }, 26 | }); 27 | return invitation; 28 | } 29 | 30 | findByInvitationByEmailAndOrganization( 31 | email: string, 32 | organizationId: string, 33 | token: string 34 | ) { 35 | return this.prisma.invitation.findFirst({ 36 | where: { email, organizationId, token }, 37 | }); 38 | } 39 | 40 | acceptInvite(id: string) { 41 | return this.prisma.invitation.update({ 42 | where: { id }, 43 | data: { 44 | inviteAccepted: true, 45 | token: null, 46 | }, 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /apps/frontend/utils/auth/withEmailVerificationRequired.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType, useEffect, FC } from 'react'; 2 | import Router from 'next/router'; 3 | import { useAuth } from '../../context/AuthContext'; 4 | import { withAuthenticationRequired } from '@auth0/auth0-react'; 5 | import { AuthLoading } from '../../components/common'; 6 | import { Frontend_Routes } from '@fest/shared'; 7 | 8 | /** 9 | * A HOC requiring email verification to access specific page. 10 | * 11 | * withAuthenticationRequired from @auth0/auth0-react package check's whether is authenticated in auth0 or not. 12 | * This one however, check's if authUser's record exists in our database, and their email is verified. 13 | */ 14 | export const withEmailVerificationRequired =

( 15 | Component: ComponentType

16 | ): FC

=> { 17 | return withAuthenticationRequired( 18 | function WithEmailVerificationRequired(props: P): JSX.Element { 19 | const { authUser, isAuthLoading } = useAuth(); 20 | 21 | useEffect(() => { 22 | if (isAuthLoading) return; 23 | if (!authUser?.emailVerified) { 24 | Router.push(Frontend_Routes.GET_STARTED_EMAIL_CONFIRM); 25 | return; 26 | } 27 | }, [isAuthLoading, authUser?.emailVerified]); 28 | 29 | return authUser?.emailVerified ? : null; 30 | }, 31 | { 32 | onRedirecting: () => , 33 | } 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /apps/frontend/components/common/LoginButton.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth0 } from '@auth0/auth0-react'; 2 | import { Button, ButtonProps } from '@mui/material'; 3 | import { Google as GoogleIcon, Email as EmailIcon } from '@mui/icons-material'; 4 | 5 | interface LoginButtonProps extends ButtonProps { 6 | connection?: null | 'google-oauth2'; // null is auth0 7 | screenHint?: 'login' | 'signup'; 8 | display?: 'popup' | 'page'; 9 | children: string; 10 | // It must be whitelisted in the "Allowed Callback URLs" field in our Auth0 Application's settings. 11 | redirectUri?: string; 12 | displayIcon?: boolean; 13 | } 14 | 15 | const LoginButton = ({ 16 | connection = null, 17 | screenHint = 'login', 18 | display = 'popup', 19 | children, 20 | redirectUri, 21 | displayIcon, 22 | ...buttonProps 23 | }: LoginButtonProps) => { 24 | const { loginWithRedirect, loginWithPopup } = useAuth0(); 25 | 26 | const options = { 27 | screen_hint: screenHint, 28 | ...(connection && { connection }), 29 | ...(redirectUri && { redirect_uri: redirectUri }), 30 | }; 31 | 32 | return ( 33 | 45 | ); 46 | }; 47 | 48 | export default LoginButton; 49 | -------------------------------------------------------------------------------- /apps/api/src/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { ExtractJwt, Strategy } from 'passport-jwt'; 4 | import { passportJwtSecret } from 'jwks-rsa'; 5 | import { ConfigService } from '@nestjs/config'; 6 | import { AuthService } from './auth.service'; 7 | import { transformAuthUserPayload } from '../common'; 8 | import { AuthProviders, findAuthProviderFromAuth0Id } from '@fest/shared'; 9 | 10 | @Injectable() 11 | export class JwtStrategy extends PassportStrategy(Strategy) { 12 | constructor(config: ConfigService, private authService: AuthService) { 13 | super({ 14 | secretOrKeyProvider: passportJwtSecret({ 15 | cache: true, 16 | rateLimit: true, 17 | jwksRequestsPerMinute: 5, 18 | jwksUri: `${config.get('AUTH0_DOMAIN')}/.well-known/jwks.json`, 19 | }), 20 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 21 | audience: config.get('AUTH0_AUDIENCE'), 22 | issuer: `${config.get('AUTH0_DOMAIN')}/`, 23 | algorithms: ['RS256'], 24 | }); 25 | } 26 | 27 | async validate(payload: any): Promise { 28 | const authProvider: AuthProviders = findAuthProviderFromAuth0Id( 29 | payload.sub 30 | ); 31 | 32 | let user; 33 | if (authProvider === AuthProviders.GOOGLE) { 34 | user = await this.authService.findOneByGoogleId(payload.sub); 35 | } else { 36 | user = await this.authService.findOneByAuth0Id(payload.sub); 37 | } 38 | 39 | return user ? transformAuthUserPayload(user) : payload; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /apps/api/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 3 | "sourceRoot": "apps/api/src", 4 | "projectType": "application", 5 | "targets": { 6 | "build": { 7 | "executor": "@nrwl/node:webpack", 8 | "outputs": ["{options.outputPath}"], 9 | "options": { 10 | "outputPath": "dist/apps/api", 11 | "main": "apps/api/src/main.ts", 12 | "tsConfig": "apps/api/tsconfig.app.json", 13 | "assets": ["apps/api/src/assets"] 14 | }, 15 | "configurations": { 16 | "production": { 17 | "optimization": true, 18 | "extractLicenses": true, 19 | "inspect": false, 20 | "fileReplacements": [ 21 | { 22 | "replace": "apps/api/src/environments/environment.ts", 23 | "with": "apps/api/src/environments/environment.prod.ts" 24 | } 25 | ] 26 | } 27 | } 28 | }, 29 | "serve": { 30 | "executor": "@nrwl/node:node", 31 | "options": { 32 | "buildTarget": "api:build" 33 | }, 34 | "configurations": { 35 | "production": { 36 | "buildTarget": "api:build:production" 37 | } 38 | } 39 | }, 40 | "lint": { 41 | "executor": "@nrwl/linter:eslint", 42 | "outputs": ["{options.outputFile}"], 43 | "options": { 44 | "lintFilePatterns": ["apps/api/**/*.ts"] 45 | } 46 | }, 47 | "test": { 48 | "executor": "@nrwl/jest:jest", 49 | "outputs": ["coverage/apps/api"], 50 | "options": { 51 | "jestConfig": "apps/api/jest.config.ts", 52 | "passWithNoTests": true 53 | } 54 | } 55 | }, 56 | "tags": [] 57 | } 58 | -------------------------------------------------------------------------------- /apps/frontend/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 3 | "sourceRoot": "apps/frontend", 4 | "projectType": "application", 5 | "targets": { 6 | "build": { 7 | "executor": "@nrwl/next:build", 8 | "outputs": ["{options.outputPath}"], 9 | "defaultConfiguration": "production", 10 | "options": { 11 | "root": "apps/frontend", 12 | "outputPath": "dist/apps/frontend" 13 | }, 14 | "configurations": { 15 | "development": {}, 16 | "production": {} 17 | } 18 | }, 19 | "serve": { 20 | "executor": "@nrwl/next:server", 21 | "defaultConfiguration": "development", 22 | "options": { 23 | "buildTarget": "frontend:build", 24 | "dev": true 25 | }, 26 | "configurations": { 27 | "development": { 28 | "buildTarget": "frontend:build:development", 29 | "dev": true, 30 | "port": 3000 31 | }, 32 | "production": { 33 | "buildTarget": "frontend:build:production", 34 | "dev": false 35 | } 36 | } 37 | }, 38 | "export": { 39 | "executor": "@nrwl/next:export", 40 | "options": { 41 | "buildTarget": "frontend:build:production" 42 | } 43 | }, 44 | "test": { 45 | "executor": "@nrwl/jest:jest", 46 | "outputs": ["coverage/apps/frontend"], 47 | "options": { 48 | "jestConfig": "apps/frontend/jest.config.ts", 49 | "passWithNoTests": true 50 | } 51 | }, 52 | "lint": { 53 | "executor": "@nrwl/linter:eslint", 54 | "outputs": ["{options.outputFile}"], 55 | "options": { 56 | "lintFilePatterns": ["apps/frontend/**/*.{ts,tsx,js,jsx}"] 57 | } 58 | } 59 | }, 60 | "tags": [] 61 | } 62 | -------------------------------------------------------------------------------- /docs/auth0.md: -------------------------------------------------------------------------------- 1 | # auth0 2 | 3 | ## API Creation 4 | 5 | docs: https://auth0.com/blog/developing-a-secure-api-with-nestjs-adding-authorization/ 6 | 7 | - After creating your tenant, you need to create an Auth0 [API](https://manage.auth0.com/?_ga=2.69504340.598902652.1658219820-1451879670.1656320355&_gl=1*1x1q696*rollup_ga*MTQ1MTg3OTY3MC4xNjU2MzIwMzU1*rollup_ga_F1G3E656YZ*MTY1ODMxNzE4OC4yMy4wLjE2NTgzMTcxODguNjA.#/apis), which is an API that you define within your Auth0 tenant and that you can consume from your applications to process authentication and authorization requests. 8 | - Add a Name to your API: 9 | - Set the Identifier: {{https://menu-api.demo.com}} 10 | - Leave the signing algorithm as `RS256`. It's the best option from a security standpoint. 11 | - As you may have more than one API that requires authorization services, you can create as many Auth0 APIs as you need. As such, the identifier is a unique string that Auth0 uses to differentiate between your Auth0 APIs. We recommend structuring the value of identifiers as URLs to make it easy to create unique identifiers predictably. Bear in mind that Auth0 never calls these URLs. 12 | - Once you've added those values, hit the Create button. 13 | 14 | ``` 15 | AUTH0_DOMAIN=https://.auth0.com 16 | AUTH0_AUDIENCE=https://menu-api.demo.com 17 | ``` 18 | 19 | - The AUTH0_DOMAIN is the value of the issuer property and the AUTH0_AUDIENCE is the value of the audience property, which is the same as the identifier that you created earlier. 20 | 21 | ## Frontend - SPA 22 | 23 | - Create a new SPA application in the auth0 dashboard 24 | - Grab domain and Client ID from settings 25 | - Add `http://localhost:3000` to Allowed Callback URLs, Allowed Logout URLs, Allowed Web Origins. 26 | - Edit redirect to field to ``http://localhost:3000` in password forget template. 27 | -------------------------------------------------------------------------------- /apps/api/src/common/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import { Prisma, User, OrganizationRole } from '@prisma/client'; 2 | import { USER_RELATIONS } from '../'; 3 | import { isNotEmptyArray } from '@fest/shared'; 4 | 5 | export interface OrganizationTransformed { 6 | id: string; 7 | role: OrganizationRole; 8 | assignedAt: Date; 9 | organizationId: string; 10 | name: string; 11 | logo?: string | null; 12 | createdAt: Date; 13 | updatedAt: Date; 14 | } 15 | 16 | export type UserWithRelations = Prisma.UserGetPayload<{ 17 | include: typeof USER_RELATIONS; 18 | }>; 19 | 20 | export interface AuthUser extends User { 21 | currentOrganization?: OrganizationTransformed; 22 | organizations?: OrganizationTransformed[]; 23 | } 24 | 25 | /** 26 | * Converts User, Organization, and OrganizationUsers data into more readable object. 27 | * Creates currentOrganization field on authUser object. 28 | * Removes duplicated data. 29 | */ 30 | export const transformAuthUserPayload = (authUser: UserWithRelations) => { 31 | if (!authUser) return null; 32 | if (!isNotEmptyArray(authUser?.organizations)) return authUser; 33 | 34 | const organizations = []; 35 | authUser.organizations.forEach((org) => { 36 | // Remove duplicated data 37 | delete org.userId; 38 | delete org.organizationId; 39 | const organizationData = org.organization; 40 | delete org.organization; 41 | 42 | organizations.push({ ...org, ...organizationData }); 43 | }); 44 | 45 | // Find and attach currentOrganization on user 46 | const currentOrganization = organizations.find( 47 | (org: OrganizationTransformed) => org.id === authUser.currentOrganizationId 48 | ); 49 | 50 | // Delete duplicate data 51 | delete authUser.organizations; 52 | delete authUser.currentOrganizationId; 53 | 54 | const user: AuthUser = { 55 | ...authUser, 56 | organizations, 57 | currentOrganization, 58 | }; 59 | 60 | return user; 61 | }; 62 | -------------------------------------------------------------------------------- /apps/api/src/organizations/organizations.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { OrganizationRole } from '@prisma/client'; 3 | import { PrismaService } from '../prisma/prisma.service'; 4 | import { 5 | CreateOrganizationDto, 6 | UpdateOrganizationDto, 7 | AddMemberToOrganizationDto, 8 | } from './dto'; 9 | 10 | @Injectable() 11 | export class OrganizationsService { 12 | constructor(private prisma: PrismaService) {} 13 | 14 | create(createOrganizationDto: CreateOrganizationDto) { 15 | const { userId, name } = createOrganizationDto; 16 | const organization = this.prisma.organization.create({ 17 | data: { 18 | name, 19 | members: { 20 | create: [ 21 | { 22 | role: OrganizationRole.Admin, 23 | user: { 24 | connect: { 25 | id: userId, 26 | }, 27 | }, 28 | }, 29 | ], 30 | }, 31 | }, 32 | }); 33 | return organization; 34 | } 35 | 36 | findOneByName(name: string) { 37 | return this.prisma.organization.findFirst({ where: { name } }); 38 | } 39 | 40 | findOneById(id: string) { 41 | return this.prisma.organization.findUnique({ where: { id } }); 42 | } 43 | 44 | update(id: string, updateOrganizationDto: UpdateOrganizationDto) { 45 | return this.prisma.organization.update({ 46 | where: { 47 | id, 48 | }, 49 | data: { 50 | ...updateOrganizationDto, 51 | }, 52 | }); 53 | } 54 | 55 | addMemberToOrganization( 56 | addMemberToOrganizationDto: AddMemberToOrganizationDto 57 | ) { 58 | const { userId, organizationId, OrganizationRole } = 59 | addMemberToOrganizationDto; 60 | return this.prisma.organizationUser.create({ 61 | data: { 62 | role: OrganizationRole, 63 | organization: { 64 | connect: { 65 | id: organizationId, 66 | }, 67 | }, 68 | user: { 69 | connect: { 70 | id: userId, 71 | }, 72 | }, 73 | }, 74 | }); 75 | } 76 | 77 | remove(id: string) { 78 | return this.prisma.organization.delete({ where: { id } }); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /apps/frontend/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from 'next/app'; 2 | import { AppState, Auth0Provider } from '@auth0/auth0-react'; 3 | import Router from 'next/router'; 4 | import Head from 'next/head'; 5 | import axios from 'axios'; 6 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 7 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; 8 | import { ThemeProvider } from '@mui/material/styles'; 9 | import CssBaseline from '@mui/material/CssBaseline'; 10 | import { CacheProvider, EmotionCache } from '@emotion/react'; 11 | 12 | import { AuthProvider } from '../context/AuthContext'; 13 | import { createEmotionCache, theme } from '../utils'; 14 | 15 | const queryClient = new QueryClient(); 16 | 17 | // Client-side cache, shared for the whole session of the user in the browser. 18 | const clientSideEmotionCache = createEmotionCache(); 19 | 20 | interface MyAppProps extends AppProps { 21 | emotionCache?: EmotionCache; 22 | } 23 | 24 | axios.defaults.baseURL = process.env.NEXT_PUBLIC_API_URL; 25 | 26 | function CustomApp(props: MyAppProps) { 27 | const { Component, emotionCache = clientSideEmotionCache, pageProps } = props; 28 | 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | { 41 | Router.replace(appState?.returnTo || '/'); 42 | }} 43 | scope="openid email profile" 44 | > 45 | 46 | 47 | 48 | 49 | 50 | 51 | {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | ); 60 | } 61 | 62 | export default CustomApp; 63 | -------------------------------------------------------------------------------- /apps/frontend/services/auth.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { useMutation, useQuery } from '@tanstack/react-query'; 3 | 4 | export const useGetAuthUser = () => { 5 | const getAuthUser = async () => { 6 | const { data } = await axios('/auth/me'); 7 | return data; 8 | }; 9 | 10 | const { data, isLoading, refetch } = useQuery(['getAuthUser'], getAuthUser, { 11 | enabled: false, 12 | }); 13 | 14 | return { isLoading, data, refetch }; 15 | }; 16 | 17 | export const useCreateUserBasedOnAuth0User = () => { 18 | interface createUserBasedOnAuth0UserProps { 19 | auth0Id?: string; 20 | googleId?: string; 21 | email: string; 22 | fullName?: string; 23 | avatar?: string; 24 | emailVerified?: boolean; 25 | } 26 | 27 | const createUserBasedOnAuth0User = async ( 28 | fields: createUserBasedOnAuth0UserProps 29 | ) => { 30 | const { data } = await axios.post( 31 | '/auth/create-user-based-on-auth0-user', 32 | fields 33 | ); 34 | return data; 35 | }; 36 | 37 | return useMutation(createUserBasedOnAuth0User); 38 | }; 39 | 40 | interface updateUserProps { 41 | userId: string; 42 | fields: { 43 | auth0Id?: string; 44 | googleId?: string; 45 | fullName?: string; 46 | avatar?: string; 47 | emailVerified?: boolean; 48 | }; 49 | } 50 | 51 | export const useUpdateUser = (): any => { 52 | const updateUser = async ({ userId, fields }: updateUserProps) => { 53 | const { data } = await axios.patch(`/auth/update/${userId}`, fields); 54 | return data; 55 | }; 56 | 57 | return useMutation(updateUser); 58 | }; 59 | 60 | export const useSendEmailConfirmation = () => { 61 | const sendEmailConfirmation = async () => { 62 | const { data } = await axios.post('/auth/send-email-confirmation'); 63 | return data; 64 | }; 65 | return useMutation(sendEmailConfirmation); 66 | }; 67 | 68 | export const useValidateEmailByCode = (): any => { 69 | const validateEmailByCode = async (code: string) => { 70 | const { data } = await axios.post('/auth/confirm-email-code', { code }); 71 | return data; 72 | }; 73 | return useMutation(validateEmailByCode); 74 | }; 75 | 76 | export const useValidateEmailByToken = (): any => { 77 | const validateEmailByToken = async (token: string) => { 78 | const { data } = await axios.post('/auth/confirm-email-token', { token }); 79 | return data; 80 | }; 81 | return useMutation(validateEmailByToken); 82 | }; 83 | -------------------------------------------------------------------------------- /apps/api/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | enum OrganizationRole { 11 | Member 12 | Moderator 13 | Admin 14 | } 15 | 16 | model User { 17 | id String @id @default(cuid()) 18 | auth0Id String? @unique 19 | googleId String? @unique 20 | fullName String? 21 | avatar String? 22 | currentOrganizationId String? 23 | email String @unique 24 | emailVerified Boolean @default(false) 25 | emailVerificationToken String? 26 | emailVerificationCode String? 27 | emailVerificationCodeExpires DateTime? 28 | emailVerificationLinkSent Boolean @default(false) 29 | 30 | // timestamps 31 | createdAt DateTime @default(now()) 32 | updatedAt DateTime @updatedAt 33 | 34 | // Relations 35 | organizations OrganizationUser[] 36 | invitations Invitation[] 37 | } 38 | 39 | model Organization { 40 | id String @id @default(cuid()) 41 | name String @unique 42 | logo String? 43 | 44 | // timestamps 45 | createdAt DateTime @default(now()) 46 | updatedAt DateTime @updatedAt 47 | 48 | // Relations 49 | members OrganizationUser[] 50 | invitations Invitation[] 51 | } 52 | 53 | model OrganizationUser { 54 | role OrganizationRole @default(Member) 55 | 56 | // timestamps 57 | assignedAt DateTime @default(now()) 58 | 59 | // relations 60 | user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) 61 | userId String 62 | organization Organization @relation(fields: [organizationId], references: [id], onUpdate: Cascade, onDelete: Cascade) 63 | organizationId String 64 | 65 | @@id([userId, organizationId]) 66 | } 67 | 68 | model Invitation { 69 | id String @id @default(cuid()) 70 | 71 | role OrganizationRole @default(Member) 72 | email String 73 | inviteAccepted Boolean? @default(false) 74 | token String? 75 | 76 | // relations 77 | inviter User @relation(fields: [inviterId], references: [id]) 78 | inviterId String 79 | 80 | organization Organization @relation(fields: [organizationId], references: [id], onUpdate: Cascade, onDelete: Cascade) 81 | organizationId String 82 | } 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fest 2 | 3 | Fest is a SaaS boilerplate built with Node.js & React. It's equipped with the following features: 4 | 5 | - User authentication and authorization with email verification and password reset. 6 | - Organizations management system. 7 | - Invite system: users can join organizations by having different roles. 8 | - Secure API endpoints and Front-end routes with role-based authorization. 9 | 10 |

11 | 12 | 13 | 14 | 15 | 16 | 17 |

18 | 19 | ## Tech Stack 20 | 21 | The repository is structured as a Monorepo using [Nx](https://nx.dev). It contains two apps: 22 | 23 | - [api](./apps/api) A [Nest.js](https://nestjs.com/) application, with [Prisma ORM](https://www.prisma.io/). 24 | - [frontend](./apps/frontend) A [Next.js](https://nextjs.org/) application with [MUI](https://mui.com/) React components. 25 | 26 | And a [shared](./libs/shared) library for sharing common Typescript types, constants, and utility functions across apps. 27 | 28 | [auth0](https://auth0.com/) is used for Identity management and PostgreSQL as a database. 29 | 30 | ## Requirements 31 | 32 | - You'll need docker installed on your machine to run the PostgreSQL. 33 | - For identity management to work, you need to create an account in [auth0](https://auth0.com/) and create two apps in there as described in [here](./docs/auth0.md). 34 | - [Postmark](https://postmarkapp.com/) is used in the repository as an email client. To send emails with Postmark, grab the key from their dashboard and add it to `apps/api/.env`. If you want to use another email client, change the corresponding code in `apps/api/src/mail.service.ts`. 35 | 36 | ## Getting started 37 | 38 | - Clone the repo: `git clone https://github.com/DimiMikadze/fest.git`. 39 | - Install dependencies: `yarn`. 40 | - Rename `apps/api/.env.example` to `.env` and `apps/frontend/.env.local.example` to `.env.local` and update environment variables. 41 | - Navigate to the `apps/api` directory and run `docker-compose up`, to run the PostgreSQL instance. 42 | - run `yarn prisma:migrate:dev init` to run the initial migrations. 43 | - run `yarn dev` from the project's root, to run API and frontend apps in the development mode. 44 | 45 | ## License 46 | 47 | Fest is an open-source software [licensed as MIT](./LICENSE). 48 | -------------------------------------------------------------------------------- /apps/frontend/pages/get-started/invitations.tsx: -------------------------------------------------------------------------------- 1 | import { Button, TextField, Typography } from '@mui/material'; 2 | import React, { ChangeEvent, SyntheticEvent, useState } from 'react'; 3 | import { GetStartedLayout } from '../../components/get-started'; 4 | import { Link } from '../../components/common'; 5 | import { withOrganizationRequired } from '../../utils'; 6 | import { useCreateInvitation } from '../../services'; 7 | import { Frontend_Routes } from '@fest/shared'; 8 | import { useAuth } from '../../context/AuthContext'; 9 | import { useRouter } from 'next/router'; 10 | 11 | const GetStartedInvitations = () => { 12 | const { authUser } = useAuth(); 13 | const router = useRouter(); 14 | const [email, setEmail] = useState(''); 15 | 16 | const { 17 | mutateAsync: mutationCreateInvitation, 18 | isError: isErrorCreateInvitation, 19 | error: errorCreateInvitation, 20 | isLoading: isLoadingCreateInvitation, 21 | } = useCreateInvitation(); 22 | 23 | const onChange = (e: ChangeEvent) => { 24 | setEmail(e.target.value); 25 | }; 26 | 27 | const onSubmit = async (e: SyntheticEvent) => { 28 | e.preventDefault(); 29 | 30 | try { 31 | await mutationCreateInvitation({ 32 | email, 33 | organizationId: authUser.currentOrganization.id, 34 | inviterId: authUser.id, 35 | }); 36 | console.log('Invite has been sent successfully.'); 37 | return router.push(Frontend_Routes.HOME); 38 | } catch (error) { 39 | console.log('Invitation sending failed', error); 40 | } 41 | }; 42 | 43 | return ( 44 | 45 | 46 | Invite teammate 47 | 48 | 49 | 61 | 62 | 71 | 72 | 73 | Skip this step 74 | 75 | 76 | ); 77 | }; 78 | 79 | export default withOrganizationRequired(GetStartedInvitations); 80 | -------------------------------------------------------------------------------- /apps/frontend/pages/get-started/organization.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Stack, TextField, Typography } from '@mui/material'; 2 | import { useRouter } from 'next/router'; 3 | import React, { ChangeEvent, SyntheticEvent, useState } from 'react'; 4 | import { Frontend_Routes } from '@fest/shared'; 5 | import { GetStartedLayout } from '../../components/get-started'; 6 | import { useAuth } from '../../context/AuthContext'; 7 | import { useCreateOrganization } from '../../services'; 8 | import { withEmailVerificationRequired } from '../../utils'; 9 | 10 | const GetStartedOrganization = () => { 11 | const [name, setName] = useState(''); 12 | const router = useRouter(); 13 | 14 | const { authUser, refetchAuthUser } = useAuth(); 15 | const { 16 | isLoading: isLoadingCreateOrganization, 17 | isError: isErrorCreateOrganization, 18 | error: errorCreateOrganization, 19 | mutateAsync: mutationCreateOrganization, 20 | } = useCreateOrganization(); 21 | 22 | const onChange = (e: ChangeEvent) => { 23 | e.preventDefault(); 24 | setName(e.target.value); 25 | }; 26 | 27 | const onSubmit = async (e: SyntheticEvent) => { 28 | e.preventDefault(); 29 | 30 | try { 31 | await mutationCreateOrganization({ name, userId: authUser.id }); 32 | await refetchAuthUser(); 33 | console.log( 34 | 'Organization has been created successfully, and authUser has been re-fetched' 35 | ); 36 | return router.push(Frontend_Routes.GET_STARTED_INVITATIONS); 37 | } catch (error) { 38 | console.log('Error detected while creating an organization', error); 39 | } 40 | }; 41 | 42 | return ( 43 | 44 | 45 | Create a new workspace 46 | 47 | 48 | 49 |
50 | 51 | 63 | 64 | 73 | 74 |
75 |
76 |
77 | ); 78 | }; 79 | 80 | export default withEmailVerificationRequired(GetStartedOrganization); 81 | -------------------------------------------------------------------------------- /apps/frontend/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Document, { Html, Head, Main, NextScript } from 'next/document'; 3 | import createEmotionServer from '@emotion/server/create-instance'; 4 | import { theme, createEmotionCache } from '../utils'; 5 | 6 | export default class MyDocument extends Document { 7 | render() { 8 | return ( 9 | 10 | 11 | {/* PWA primary color */} 12 | 13 | 14 | 18 | 19 | {(this.props as any).emotionStyleTags} 20 | 21 | 22 |
23 | 24 | 25 | 26 | ); 27 | } 28 | } 29 | 30 | // `getInitialProps` belongs to `_document` (instead of `_app`), 31 | // it's compatible with static-site generation (SSG). 32 | MyDocument.getInitialProps = async (ctx) => { 33 | // Resolution order 34 | // 35 | // On the server: 36 | // 1. app.getInitialProps 37 | // 2. page.getInitialProps 38 | // 3. document.getInitialProps 39 | // 4. app.render 40 | // 5. page.render 41 | // 6. document.render 42 | // 43 | // On the server with error: 44 | // 1. document.getInitialProps 45 | // 2. app.render 46 | // 3. page.render 47 | // 4. document.render 48 | // 49 | // On the client 50 | // 1. app.getInitialProps 51 | // 2. page.getInitialProps 52 | // 3. app.render 53 | // 4. page.render 54 | 55 | const originalRenderPage = ctx.renderPage; 56 | 57 | // You can consider sharing the same Emotion cache between all the SSR requests to speed up performance. 58 | // However, be aware that it can have global side effects. 59 | const cache = createEmotionCache(); 60 | const { extractCriticalToChunks } = createEmotionServer(cache); 61 | 62 | ctx.renderPage = () => 63 | originalRenderPage({ 64 | enhanceApp: (App: any) => 65 | function EnhanceApp(props) { 66 | return ; 67 | }, 68 | }); 69 | 70 | const initialProps = await Document.getInitialProps(ctx); 71 | // This is important. It prevents Emotion to render invalid HTML. 72 | // See https://github.com/mui/material-ui/issues/26561#issuecomment-855286153 73 | const emotionStyles = extractCriticalToChunks(initialProps.html); 74 | const emotionStyleTags = emotionStyles.styles.map((style) => ( 75 |