(
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 | : )}
35 | variant="outlined"
36 | onClick={() =>
37 | display === 'popup'
38 | ? loginWithPopup(options)
39 | : loginWithRedirect(options)
40 | }
41 | {...buttonProps}
42 | >
43 | {children}
44 |
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 |
69 | Send Invitation
70 |
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 |
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 |
81 | ));
82 |
83 | return {
84 | ...initialProps,
85 | emotionStyleTags,
86 | };
87 | };
88 |
--------------------------------------------------------------------------------
/apps/api/src/auth/auth.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 | import { USER_RELATIONS } from '../common';
4 | import { PrismaService } from '../prisma/prisma.service';
5 | import { CreateUserDto, UpdateUserDto } from './dto';
6 |
7 | @Injectable()
8 | export class AuthService {
9 | constructor(private config: ConfigService, private prisma: PrismaService) {}
10 |
11 | createUserBasedOnAuth0User(createUserDto: CreateUserDto) {
12 | const user = this.prisma.user.create({
13 | data: {
14 | ...createUserDto,
15 | },
16 | include: USER_RELATIONS,
17 | });
18 |
19 | return user;
20 | }
21 |
22 | findOneByEmail(email: string) {
23 | return this.prisma.user.findUnique({
24 | where: { email },
25 | include: USER_RELATIONS,
26 | });
27 | }
28 |
29 | findOneByAuth0Id(id: string) {
30 | return this.prisma.user.findUnique({
31 | where: {
32 | auth0Id: id,
33 | },
34 | include: USER_RELATIONS,
35 | });
36 | }
37 |
38 | findOneByGoogleId(id: string) {
39 | return this.prisma.user.findUnique({
40 | where: {
41 | googleId: id,
42 | },
43 | include: USER_RELATIONS,
44 | });
45 | }
46 |
47 | update(id: string, updateUserDto: UpdateUserDto) {
48 | return this.prisma.user.update({
49 | where: { id },
50 | data: {
51 | ...updateUserDto,
52 | },
53 | include: USER_RELATIONS,
54 | });
55 | }
56 |
57 | generateRandomString(length: number) {
58 | const chars =
59 | '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
60 | let result = '';
61 | for (let i = length; i > 0; --i) {
62 | result += chars[Math.round(Math.random() * (chars.length - 1))];
63 | }
64 | return result;
65 | }
66 |
67 | updateUsersEmailVerificationToken(
68 | userId: string,
69 | token: string,
70 | code: string,
71 | codeExpires: Date
72 | ) {
73 | return this.prisma.user.update({
74 | where: {
75 | id: userId,
76 | },
77 | data: {
78 | emailVerificationToken: token,
79 | emailVerificationCode: code,
80 | emailVerificationCodeExpires: codeExpires,
81 | emailVerificationLinkSent: true,
82 | },
83 | });
84 | }
85 |
86 | findUserByCodeAndEmail(email: string, code: string) {
87 | return this.prisma.user.findFirst({
88 | where: { email, emailVerificationCode: code },
89 | });
90 | }
91 |
92 | findUserByTokenAndEmail(email: string, token: string) {
93 | return this.prisma.user.findFirst({
94 | where: { email, emailVerificationToken: token },
95 | });
96 | }
97 |
98 | setEmailVerifiedToTrue(id: string) {
99 | return this.prisma.user.update({
100 | where: {
101 | id,
102 | },
103 | data: {
104 | emailVerified: true,
105 | emailVerificationCode: null,
106 | emailVerificationToken: null,
107 | emailVerificationCodeExpires: null,
108 | },
109 | });
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/docs/nx.md:
--------------------------------------------------------------------------------
1 | 🔎 **Smart, Fast and Extensible Build System**
2 |
3 | ## Adding capabilities to your workspace
4 |
5 | Nx supports many plugins which add capabilities for developing different types of applications and different tools.
6 |
7 | These capabilities include generating applications, libraries, etc as well as the devtools to test, and build projects as well.
8 |
9 | Below are our core plugins:
10 |
11 | - [React](https://reactjs.org)
12 | - `npm install --save-dev @nrwl/react`
13 | - Web (no framework frontends)
14 | - `npm install --save-dev @nrwl/web`
15 | - [Angular](https://angular.io)
16 | - `npm install --save-dev @nrwl/angular`
17 | - [Nest](https://nestjs.com)
18 | - `npm install --save-dev @nrwl/nest`
19 | - [Express](https://expressjs.com)
20 | - `npm install --save-dev @nrwl/express`
21 | - [Node](https://nodejs.org)
22 | - `npm install --save-dev @nrwl/node`
23 |
24 | There are also many [community plugins](https://nx.dev/community) you could add.
25 |
26 | ## Generate an application
27 |
28 | Run `nx g @nrwl/react:app my-app` to generate an application.
29 |
30 | > You can use any of the plugins above to generate applications as well.
31 |
32 | When using Nx, you can create multiple applications and libraries in the same workspace.
33 |
34 | ## Generate a library
35 |
36 | Run `nx g @nrwl/react:lib my-lib` to generate a library.
37 |
38 | > You can also use any of the plugins above to generate libraries as well.
39 |
40 | Libraries are shareable across libraries and applications. They can be imported from `@fest/mylib`.
41 |
42 | ## Development server
43 |
44 | Run `nx serve my-app` for a dev server. Navigate to http://localhost:3000/. The app will automatically reload if you change any of the source files.
45 |
46 | ## Code scaffolding
47 |
48 | Run `nx g @nrwl/react:component my-component --project=my-app` to generate a new component.
49 |
50 | ## Build
51 |
52 | Run `nx build my-app` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build.
53 |
54 | ## Running unit tests
55 |
56 | Run `nx test my-app` to execute the unit tests via [Jest](https://jestjs.io).
57 |
58 | Run `nx affected:test` to execute the unit tests affected by a change.
59 |
60 | ## Running end-to-end tests
61 |
62 | Run `nx e2e my-app` to execute the end-to-end tests via [Cypress](https://www.cypress.io).
63 |
64 | Run `nx affected:e2e` to execute the end-to-end tests affected by a change.
65 |
66 | ## Understand your workspace
67 |
68 | Run `nx graph` to see a diagram of the dependencies of your projects.
69 |
70 | ## Further help
71 |
72 | Visit the [Nx Documentation](https://nx.dev) to learn more.
73 |
74 | ## ☁ Nx Cloud
75 |
76 | ### Distributed Computation Caching & Distributed Task Execution
77 |
78 |
79 |
80 | Nx Cloud pairs with Nx in order to enable you to build and test code more rapidly, by up to 10 times. Even teams that are new to Nx can connect to Nx Cloud and start saving time instantly.
81 |
82 | Teams using Nx gain the advantage of building full-stack applications with their preferred framework alongside Nx’s advanced code generation and project dependency graph, plus a unified experience for both frontend and backend developers.
83 |
84 | Visit [Nx Cloud](https://nx.app/) to learn more.
85 |
--------------------------------------------------------------------------------
/apps/api/migrations/20220804150309_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateEnum
2 | CREATE TYPE "OrganizationRole" AS ENUM ('Member', 'Moderator', 'Admin');
3 |
4 | -- CreateTable
5 | CREATE TABLE "User" (
6 | "id" TEXT NOT NULL,
7 | "auth0Id" TEXT,
8 | "googleId" TEXT,
9 | "fullName" TEXT,
10 | "avatar" TEXT,
11 | "currentOrganizationId" TEXT,
12 | "email" TEXT NOT NULL,
13 | "emailVerified" BOOLEAN NOT NULL DEFAULT false,
14 | "emailVerificationToken" TEXT,
15 | "emailVerificationCode" TEXT,
16 | "emailVerificationCodeExpires" TIMESTAMP(3),
17 | "emailVerificationLinkSent" BOOLEAN NOT NULL DEFAULT false,
18 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
19 | "updatedAt" TIMESTAMP(3) NOT NULL,
20 |
21 | CONSTRAINT "User_pkey" PRIMARY KEY ("id")
22 | );
23 |
24 | -- CreateTable
25 | CREATE TABLE "Organization" (
26 | "id" TEXT NOT NULL,
27 | "name" TEXT NOT NULL,
28 | "logo" TEXT,
29 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
30 | "updatedAt" TIMESTAMP(3) NOT NULL,
31 |
32 | CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
33 | );
34 |
35 | -- CreateTable
36 | CREATE TABLE "OrganizationUser" (
37 | "role" "OrganizationRole" NOT NULL DEFAULT 'Member',
38 | "assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
39 | "userId" TEXT NOT NULL,
40 | "organizationId" TEXT NOT NULL,
41 |
42 | CONSTRAINT "OrganizationUser_pkey" PRIMARY KEY ("userId","organizationId")
43 | );
44 |
45 | -- CreateTable
46 | CREATE TABLE "Invitation" (
47 | "id" TEXT NOT NULL,
48 | "role" "OrganizationRole" NOT NULL DEFAULT 'Member',
49 | "email" TEXT NOT NULL,
50 | "inviteAccepted" BOOLEAN DEFAULT false,
51 | "token" TEXT,
52 | "inviterId" TEXT NOT NULL,
53 | "organizationId" TEXT NOT NULL,
54 |
55 | CONSTRAINT "Invitation_pkey" PRIMARY KEY ("id")
56 | );
57 |
58 | -- CreateTable
59 | CREATE TABLE "Room" (
60 | "id" TEXT NOT NULL,
61 | "name" TEXT NOT NULL,
62 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
63 | "updatedAt" TIMESTAMP(3) NOT NULL,
64 | "organizationId" TEXT NOT NULL,
65 |
66 | CONSTRAINT "Room_pkey" PRIMARY KEY ("id")
67 | );
68 |
69 | -- CreateIndex
70 | CREATE UNIQUE INDEX "User_auth0Id_key" ON "User"("auth0Id");
71 |
72 | -- CreateIndex
73 | CREATE UNIQUE INDEX "User_googleId_key" ON "User"("googleId");
74 |
75 | -- CreateIndex
76 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
77 |
78 | -- CreateIndex
79 | CREATE UNIQUE INDEX "Organization_name_key" ON "Organization"("name");
80 |
81 | -- AddForeignKey
82 | ALTER TABLE "OrganizationUser" ADD CONSTRAINT "OrganizationUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
83 |
84 | -- AddForeignKey
85 | ALTER TABLE "OrganizationUser" ADD CONSTRAINT "OrganizationUser_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
86 |
87 | -- AddForeignKey
88 | ALTER TABLE "Invitation" ADD CONSTRAINT "Invitation_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
89 |
90 | -- AddForeignKey
91 | ALTER TABLE "Invitation" ADD CONSTRAINT "Invitation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
92 |
93 | -- AddForeignKey
94 | ALTER TABLE "Room" ADD CONSTRAINT "Room_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
95 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fest",
3 | "version": "0.0.0",
4 | "license": "MIT",
5 | "scripts": {
6 | "dev": "nx run-many --parallel --target=serve --projects=api,frontend",
7 | "dev:api": "nx serve api",
8 | "dev:frontend": "nx serve frontend",
9 | "build:api": "nx build api --prod",
10 | "build:frontend": "nx build frontend --prod",
11 | "start:api": "node dist/apps/api/main",
12 | "start:frontend": "next start dist/apps/frontend",
13 | "prisma:generate-types": "prisma generate",
14 | "preprisma:migrate:dev": "yarn prisma:generate-types",
15 | "prisma:migrate:dev": "prisma migrate dev --name",
16 | "prisma:seed": "ts-node --compiler-options {\\\"module\\\":\\\"CommonJS\\\"} ./apps/api/src/prisma/seed.ts"
17 | },
18 | "private": true,
19 | "dependencies": {
20 | "@auth0/auth0-react": "^1.10.2",
21 | "@emotion/cache": "^11.9.3",
22 | "@emotion/react": "11.10.5",
23 | "@emotion/server": "^11.4.0",
24 | "@emotion/styled": "11.10.5",
25 | "@mui/icons-material": "^5.8.4",
26 | "@mui/material": "^5.9.2",
27 | "@nestjs/common": "9.2.1",
28 | "@nestjs/config": "^2.1.0",
29 | "@nestjs/core": "9.2.1",
30 | "@nestjs/mapped-types": "^1.1.0",
31 | "@nestjs/passport": "^8.2.2",
32 | "@nestjs/platform-express": "9.2.1",
33 | "@prisma/client": "^4.0.0",
34 | "@tanstack/react-query": "^4.0.10",
35 | "axios": "^0.27.2",
36 | "class-transformer": "^0.5.1",
37 | "class-validator": "^0.13.2",
38 | "core-js": "^3.6.5",
39 | "jsonwebtoken": "^8.5.1",
40 | "jwks-rsa": "^2.1.4",
41 | "next": "13.0.0",
42 | "passport": "^0.6.0",
43 | "passport-jwt": "^4.0.0",
44 | "postmark": "^3.0.12",
45 | "react": "18.2.0",
46 | "react-dom": "18.2.0",
47 | "react-hook-form": "^7.34.0",
48 | "reflect-metadata": "^0.1.13",
49 | "regenerator-runtime": "0.13.7",
50 | "rxjs": "^7.0.0",
51 | "tslib": "^2.3.0"
52 | },
53 | "devDependencies": {
54 | "@nestjs/schematics": "9.0.3",
55 | "@nestjs/testing": "9.2.1",
56 | "@nrwl/cli": "15.4.1",
57 | "@nrwl/cypress": "15.4.1",
58 | "@nrwl/eslint-plugin-nx": "15.4.1",
59 | "@nrwl/jest": "15.4.1",
60 | "@nrwl/linter": "15.4.1",
61 | "@nrwl/nest": "15.4.1",
62 | "@nrwl/next": "15.4.1",
63 | "@nrwl/node": "15.4.1",
64 | "@nrwl/react": "15.4.1",
65 | "@nrwl/web": "15.4.1",
66 | "@nrwl/workspace": "15.4.1",
67 | "@tanstack/react-query-devtools": "^4.0.10",
68 | "@testing-library/react": "13.4.0",
69 | "@types/jest": "28.1.8",
70 | "@types/node": "18.11.9",
71 | "@types/react": "18.0.25",
72 | "@types/react-dom": "18.0.9",
73 | "@typescript-eslint/eslint-plugin": "5.47.0",
74 | "@typescript-eslint/parser": "5.47.0",
75 | "babel-jest": "28.1.3",
76 | "cypress": "^9.1.0",
77 | "eslint": "~8.15.0",
78 | "eslint-config-next": "13.0.0",
79 | "eslint-config-prettier": "8.1.0",
80 | "eslint-plugin-cypress": "^2.10.3",
81 | "eslint-plugin-import": "2.26.0",
82 | "eslint-plugin-jsx-a11y": "6.6.1",
83 | "eslint-plugin-react": "7.31.11",
84 | "eslint-plugin-react-hooks": "4.6.0",
85 | "jest": "28.1.3",
86 | "nx": "15.4.1",
87 | "prettier": "^2.6.2",
88 | "prisma": "^4.0.0",
89 | "react-test-renderer": "18.2.0",
90 | "ts-jest": "28.0.8",
91 | "ts-node": "10.9.1",
92 | "typescript": "4.8.4"
93 | },
94 | "prisma": {
95 | "schema": "apps/api/schema.prisma"
96 | },
97 | "engines": {
98 | "node": "16.x"
99 | }
100 | }
101 |
102 |
--------------------------------------------------------------------------------
/apps/frontend/components/common/Link.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import clsx from 'clsx';
3 | import { useRouter } from 'next/router';
4 | import NextLink, { LinkProps as NextLinkProps } from 'next/link';
5 | import MuiLink, { LinkProps as MuiLinkProps } from '@mui/material/Link';
6 | import { styled } from '@mui/material/styles';
7 |
8 | // Add support for the sx prop for consistency with the other branches.
9 | const Anchor = styled('a')({});
10 |
11 | interface NextLinkComposedProps
12 | extends Omit, 'href'>,
13 | Omit {
14 | to: NextLinkProps['href'];
15 | linkAs?: NextLinkProps['as'];
16 | }
17 |
18 | export const NextLinkComposed = React.forwardRef<
19 | HTMLAnchorElement,
20 | NextLinkComposedProps
21 | >(function NextLinkComposed(props, ref) {
22 | const { to, linkAs, replace, scroll, shallow, prefetch, locale, ...other } =
23 | props;
24 |
25 | return (
26 |
37 |
38 |
39 | );
40 | });
41 |
42 | export type LinkProps = {
43 | activeClassName?: string;
44 | as?: NextLinkProps['as'];
45 | href: NextLinkProps['href'];
46 | linkAs?: NextLinkProps['as']; // Useful when the as prop is shallow by styled().
47 | noLinkStyle?: boolean;
48 | } & Omit &
49 | Omit;
50 |
51 | // A styled version of the Next.js Link component:
52 | // https://nextjs.org/docs/api-reference/next/link
53 | const Link = React.forwardRef(function Link(
54 | props,
55 | ref
56 | ) {
57 | const {
58 | activeClassName = 'active',
59 | as,
60 | className: classNameProps,
61 | href,
62 | linkAs: linkAsProp,
63 | locale,
64 | noLinkStyle,
65 | prefetch,
66 | replace,
67 | role, // Link don't have roles.
68 | scroll,
69 | shallow,
70 | ...other
71 | } = props;
72 |
73 | const router = useRouter();
74 | const pathname = typeof href === 'string' ? href : href.pathname;
75 | const className = clsx(classNameProps, {
76 | [activeClassName]: router.pathname === pathname && activeClassName,
77 | });
78 |
79 | const isExternal =
80 | typeof href === 'string' &&
81 | (href.indexOf('http') === 0 || href.indexOf('mailto:') === 0);
82 |
83 | if (isExternal) {
84 | if (noLinkStyle) {
85 | return ;
86 | }
87 |
88 | return ;
89 | }
90 |
91 | const linkAs = linkAsProp || as;
92 | const nextjsProps = {
93 | to: href,
94 | linkAs,
95 | replace,
96 | scroll,
97 | shallow,
98 | prefetch,
99 | locale,
100 | };
101 |
102 | if (noLinkStyle) {
103 | return (
104 |
110 | );
111 | }
112 |
113 | return (
114 |
121 | );
122 | });
123 |
124 | export default Link;
125 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | - Using welcoming and inclusive language
18 | - Being respectful of differing viewpoints and experiences
19 | - Gracefully accepting constructive criticism
20 | - Focusing on what is best for the community
21 | - Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | - The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | - Trolling, insulting/derogatory comments, and personal or political attacks
28 | - Public or private harassment
29 | - Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | - Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at dimimikadze@gmail.com All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/apps/frontend/pages/get-started/invitation-accepted.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Typography } from '@mui/material';
3 | import { GetStartedLayout } from '../../components/get-started';
4 | import { Frontend_Routes } from '@fest/shared';
5 | import { useRouter } from 'next/router';
6 | import { useAuth } from '../../context/AuthContext';
7 | import { AuthLoading, LoginButton } from '../../components/common';
8 | import { useValidateToken } from '../../services';
9 |
10 | const GetStartedInvitationAccepted = () => {
11 | const router = useRouter();
12 | const { isAuthLoading, setInvitationTokenDecoded } = useAuth();
13 | const [urlToken, setUrlToken] = useState('');
14 |
15 | const {
16 | mutateAsync: mutationValidateToken,
17 | isError: isErrorValidateToken,
18 | error: errorValidateToken,
19 | isLoading: isLoadingValidateToken,
20 | data: dataValidateToken,
21 | } = useValidateToken();
22 |
23 | /**
24 | * Responsible for getting the token from the URL and for its validation.
25 | */
26 | useEffect(() => {
27 | if (isAuthLoading) return;
28 | if (!router.isReady) return;
29 | const tokenFromTheUrl = router.query?.t as string;
30 | if (!tokenFromTheUrl) return;
31 |
32 | setUrlToken(tokenFromTheUrl);
33 |
34 | console.log('Invitation accepted: Found token in the URL');
35 | router.replace(Frontend_Routes.GET_STARTED_INVITATION_ACCEPTED, undefined, {
36 | shallow: true,
37 | });
38 |
39 | (async () => {
40 | try {
41 | const invitation = await mutationValidateToken({
42 | token: tokenFromTheUrl,
43 | });
44 | console.log('Setting invitationTokenDecoded', invitation);
45 | setInvitationTokenDecoded(invitation);
46 | } catch (error) {
47 | console.log('Token validation failed', error);
48 | }
49 | })();
50 | }, [router, isAuthLoading, mutationValidateToken, setInvitationTokenDecoded]);
51 |
52 | if (isLoadingValidateToken) return ;
53 |
54 | if (isErrorValidateToken) {
55 | return (
56 |
57 |
58 | {errorValidateToken?.response?.data?.message}
59 |
60 |
61 | );
62 | }
63 |
64 | if (router.isReady && !urlToken) {
65 | return (
66 |
67 |
68 | Token missing in the URL
69 |
70 |
71 | );
72 | }
73 |
74 | if (!dataValidateToken) return;
75 |
76 | return (
77 |
78 |
79 | Join {dataValidateToken?.organizationName} on Fest
80 |
81 |
82 |
83 | Fest is the platform for seamless communication and collaboration
84 |
85 |
86 |
87 | {dataValidateToken?.inviterEmail} has already joined.
88 |
89 |
90 |
97 | Continue with Google
98 |
99 |
100 | Continue with Email
101 |
102 |
103 | );
104 | };
105 |
106 | export default GetStartedInvitationAccepted;
107 |
--------------------------------------------------------------------------------
/apps/api/src/organizations/organizations.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Controller,
3 | Post,
4 | Body,
5 | Patch,
6 | Param,
7 | Delete,
8 | UseGuards,
9 | UnauthorizedException,
10 | BadRequestException,
11 | Logger,
12 | } from '@nestjs/common';
13 | import { OrganizationsService } from './organizations.service';
14 | import { CreateOrganizationDto } from './dto/create-organization.dto';
15 | import { UpdateOrganizationDto } from './dto/update-organization.dto';
16 | import { AddMemberAfterInviteDto, AddMemberToOrganizationDto } from './dto';
17 | import {
18 | AuthUser,
19 | BaseAuthGuard,
20 | EmailConfirmRequiredGuard,
21 | ReqUser,
22 | } from '../common';
23 | import { AuthService } from '../auth/auth.service';
24 | import { MailService } from '../mail/mail.service';
25 | import { userAcceptedInvite } from '../mail/templates/userAcceptedInvite';
26 |
27 | @Controller('organizations')
28 | export class OrganizationsController {
29 | private readonly logger = new Logger(OrganizationsController.name);
30 |
31 | constructor(
32 | private readonly organizationsService: OrganizationsService,
33 | private authService: AuthService,
34 | private mailService: MailService
35 | ) {}
36 |
37 | @Post('create')
38 | @UseGuards(EmailConfirmRequiredGuard)
39 | async create(
40 | @ReqUser() authUser: AuthUser,
41 | @Body() createOrganizationDto: CreateOrganizationDto
42 | ) {
43 | if (createOrganizationDto.userId !== authUser.id) {
44 | throw new UnauthorizedException(
45 | 'Someone else is trying to create an organization for another user'
46 | );
47 | }
48 |
49 | const isExistingOrganization =
50 | await this.organizationsService.findOneByName(createOrganizationDto.name);
51 | if (isExistingOrganization) {
52 | throw new BadRequestException('Organization name is already taken.');
53 | }
54 |
55 | // Create a new organization and add it as a currentOrganization to the authUser
56 | const organization = await this.organizationsService.create(
57 | createOrganizationDto
58 | );
59 | await this.authService.update(authUser.id, {
60 | currentOrganizationId: organization.id,
61 | });
62 |
63 | return organization;
64 | }
65 |
66 | @Post('/add-member-after-invite')
67 | @UseGuards(BaseAuthGuard)
68 | async addMemberToOrganization(
69 | @ReqUser() authUser: AuthUser,
70 | @Body() addMemberAfterInviteDto: AddMemberAfterInviteDto
71 | ) {
72 | if (addMemberAfterInviteDto.userId !== authUser.id) {
73 | this.logger.error(
74 | `Can't add a user to the organization. They are using different email address.`
75 | );
76 | throw new UnauthorizedException(
77 | 'Please login with the invited email address'
78 | );
79 | }
80 |
81 | try {
82 | // Add a new user to the invited organization
83 | const organizationUser =
84 | this.organizationsService.addMemberToOrganization(
85 | addMemberAfterInviteDto
86 | );
87 | // Add invited organization to the user's current organization
88 | await this.authService.update(authUser.id, {
89 | currentOrganizationId: addMemberAfterInviteDto.organizationId,
90 | });
91 | // Send email to the inviter to notify them that their invitee has accepted their invite
92 | await this.mailService.send({
93 | to: addMemberAfterInviteDto.inviterEmail,
94 | subject: `${
95 | authUser.fullName || authUser.email
96 | } has accepted your invite`,
97 | body: userAcceptedInvite({
98 | fullName: authUser.fullName,
99 | email: authUser.email,
100 | }),
101 | });
102 |
103 | this.logger.log(
104 | 'Member has been successfully added to the organization, organizationUser',
105 | organizationUser
106 | );
107 | return organizationUser;
108 | } catch (error) {
109 | throw new BadRequestException(
110 | 'Adding member to the organization failed',
111 | error
112 | );
113 | }
114 | }
115 |
116 | @Patch('/update/:id')
117 | update(
118 | @Param('id') id: string,
119 | @Body() updateOrganizationDto: UpdateOrganizationDto
120 | ) {
121 | return this.organizationsService.update(id, updateOrganizationDto);
122 | }
123 |
124 | @Patch('/add-member')
125 | addMember(@Body() addMemberToOrganizationDto: AddMemberToOrganizationDto) {
126 | return this.organizationsService.addMemberToOrganization(
127 | addMemberToOrganizationDto
128 | );
129 | }
130 |
131 | @Delete('/delete/:id')
132 | remove(@Param('id') id: string) {
133 | return this.organizationsService.remove(id);
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/apps/api/src/invitations/invitations.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | BadRequestException,
3 | Body,
4 | Controller,
5 | Logger,
6 | Post,
7 | UnauthorizedException,
8 | UseGuards,
9 | } from '@nestjs/common';
10 | import { ConfigService } from '@nestjs/config';
11 | import * as jwt from 'jsonwebtoken';
12 | import { Frontend_Routes } from '@fest/shared';
13 | import {
14 | AuthUser,
15 | ORGANIZATION_INVITATION_TOKEN_EXPIRY,
16 | OrganizationRequiredGuard,
17 | ReqUser,
18 | } from '../common';
19 | import { MailService } from '../mail/mail.service';
20 | import { organizationInvitation } from '../mail/templates';
21 | import { OrganizationsService } from '../organizations/organizations.service';
22 | import { UsersService } from '../users/users.service';
23 | import { CreateInvitationDto, ValidateTokenDto } from './dto';
24 | import { InvitationsService } from './invitations.service';
25 |
26 | @Controller('invitations')
27 | export class InvitationsController {
28 | private readonly logger = new Logger(InvitationsController.name);
29 |
30 | constructor(
31 | private invitationsService: InvitationsService,
32 | private mailService: MailService,
33 | private usersService: UsersService,
34 | private organizationsService: OrganizationsService,
35 | private config: ConfigService
36 | ) {}
37 |
38 | /**
39 | * Signs a new token with inviter and organization information,
40 | * Sends an email to the invitee and creates a invite record in the db.
41 | */
42 | @Post('create')
43 | @UseGuards(OrganizationRequiredGuard)
44 | async create(
45 | @ReqUser() authUser: AuthUser,
46 | @Body() createInvitationDto: CreateInvitationDto
47 | ) {
48 | const { inviterId, email, organizationId } = createInvitationDto;
49 | if (authUser.id !== inviterId) {
50 | throw new UnauthorizedException(
51 | 'Trying to invite a member on someone elses behalf'
52 | );
53 | }
54 |
55 | try {
56 | // Find information about the inviter and the organization
57 | const inviter = await this.usersService.findOneById(inviterId);
58 | const organization = await this.organizationsService.findOneById(
59 | organizationId
60 | );
61 |
62 | // Sign a new token with authUser's email and id
63 | const token = jwt.sign(
64 | {
65 | inviterId: inviter.id,
66 | inviterEmail: inviter.email,
67 | organizationName: organization.name,
68 | organizationId: organization.id,
69 | email,
70 | },
71 | this.config.get('ORGANIZATION_INVITATION_TOKEN_SECRET'),
72 | { expiresIn: ORGANIZATION_INVITATION_TOKEN_EXPIRY }
73 | );
74 |
75 | // Create an invitation record and send an email to the invitee.
76 | await this.invitationsService.create(createInvitationDto, token);
77 | await this.mailService.send({
78 | to: email,
79 | subject: `join ${organization.name} in Fest`,
80 | body: organizationInvitation({
81 | inviterName: inviter.fullName,
82 | inviterEmail: inviter.email,
83 | organizationName: organization.name,
84 | link: `${this.config.get('FRONT_END_URL')}${
85 | Frontend_Routes.GET_STARTED_INVITATION_ACCEPTED
86 | }?t=${token}`,
87 | }),
88 | });
89 |
90 | this.logger.log('Invite created successfully');
91 |
92 | return { message: 'success' };
93 | } catch (error) {
94 | this.logger.error('Creation of the invitation failed', error);
95 | throw new BadRequestException(error);
96 | }
97 | }
98 |
99 | /**
100 | * Checks the token validity, and searches invitation record with the
101 | * decoded tokens fields (inviterId and organizationId) and
102 | * returns decoded token
103 | */
104 | @Post('/validate-token')
105 | async validateToken(@Body() validateTokenDto: ValidateTokenDto) {
106 | const { token } = validateTokenDto;
107 |
108 | // Decode the token, and check its validity.
109 | let decoded;
110 | try {
111 | decoded = jwt.verify(
112 | token,
113 | this.config.get('ORGANIZATION_INVITATION_TOKEN_SECRET')
114 | );
115 | this.logger.log('Decoded token successfully', decoded);
116 | } catch (error) {
117 | this.logger.error('An error detected while decoding the token');
118 | throw new BadRequestException('Invalid token');
119 | }
120 |
121 | const { email, organizationId, organizationName, inviterId, inviterEmail } =
122 | decoded;
123 |
124 | // Check if invitation record with given email and organizationId exists in the database
125 | const invitation =
126 | await this.invitationsService.findByInvitationByEmailAndOrganization(
127 | email,
128 | organizationId,
129 | token
130 | );
131 | if (!invitation) {
132 | this.logger.error('Invitation record missing');
133 | throw new BadRequestException("Can't find the invitation");
134 | }
135 |
136 | this.logger.log('Found invitation', invitation);
137 |
138 | // Accept invitation: set inviteAccepted to true and token to null.
139 | await this.invitationsService.acceptInvite(invitation.id);
140 |
141 | this.logger.log('Invite accepted');
142 |
143 | // Return decoded token fields
144 | return { email, organizationId, organizationName, inviterId, inviterEmail };
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/apps/api/src/prisma/seed.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient, OrganizationRole } from '@prisma/client';
2 | import * as dotenv from 'dotenv';
3 |
4 | const prisma = new PrismaClient();
5 |
6 | async function emptyTables() {
7 | await prisma.organization.deleteMany();
8 | await prisma.user.deleteMany();
9 | }
10 |
11 | async function createUser({
12 | auth0Id,
13 | email,
14 | }: {
15 | auth0Id: string;
16 | email: string;
17 | }) {
18 | try {
19 | const user = await prisma.user.create({
20 | data: {
21 | auth0Id,
22 | email,
23 | },
24 | });
25 | console.log('createUser success, user: ', user);
26 | return user;
27 | } catch (error) {
28 | console.log('createUser failed, error:', error);
29 | }
30 | }
31 |
32 | async function createOrganization({ name }: { name: string }) {
33 | try {
34 | const organization = await prisma.organization.create({
35 | data: {
36 | name,
37 | },
38 | });
39 | console.log('createOrganization success, organization:', organization);
40 | return organization;
41 | } catch (error) {
42 | console.log('createOrganization failed, error:', error);
43 | }
44 | }
45 |
46 | async function createUserOrganizationAndOrganizationUser({
47 | auth0Id,
48 | email,
49 | userRole,
50 | organizationName,
51 | }: {
52 | auth0Id: string;
53 | email: string;
54 | userRole: OrganizationRole;
55 | organizationName: string;
56 | }) {
57 | try {
58 | const result = await prisma.user.create({
59 | data: {
60 | auth0Id,
61 | email,
62 | organizations: {
63 | create: [
64 | {
65 | role: userRole,
66 | organization: {
67 | create: {
68 | name: organizationName,
69 | },
70 | },
71 | },
72 | ],
73 | },
74 | },
75 | });
76 | console.log('createUserOrganizationAndOrganizationUser success');
77 | return result;
78 | } catch (error) {
79 | console.log(
80 | 'createUserOrganizationAndOrganizationUser failed, error:',
81 | error
82 | );
83 | }
84 | }
85 |
86 | async function createUserAndAssignToExistingOrganization({
87 | auth0Id,
88 | email,
89 | userRole,
90 | organizationId,
91 | }: {
92 | auth0Id: string;
93 | email: string;
94 | userRole: OrganizationRole;
95 | organizationId: string;
96 | }) {
97 | try {
98 | const result = await prisma.user.create({
99 | data: {
100 | auth0Id,
101 | email,
102 | organizations: {
103 | create: [
104 | {
105 | role: userRole,
106 | organization: {
107 | connect: {
108 | id: organizationId,
109 | },
110 | },
111 | },
112 | ],
113 | },
114 | },
115 | });
116 | console.log('createUserAndAssignToExistingOrganization success');
117 | return result;
118 | } catch (error) {
119 | console.log(
120 | 'createUserAndAssignToExistingOrganization failed, error:',
121 | error
122 | );
123 | }
124 | }
125 |
126 | async function createOrganizationAndAssignToExistingUser({
127 | organizationName,
128 | userRole,
129 | userId,
130 | }: {
131 | organizationName: string;
132 | userRole: OrganizationRole;
133 | userId: string;
134 | }) {
135 | try {
136 | const result = await prisma.organization.create({
137 | data: {
138 | name: organizationName,
139 | members: {
140 | create: [
141 | {
142 | role: userRole,
143 | user: {
144 | connect: {
145 | id: userId,
146 | },
147 | },
148 | },
149 | ],
150 | },
151 | },
152 | });
153 | console.log('createOrganizationAndAssignToExistingUser success');
154 | return result;
155 | } catch (error) {
156 | console.log(
157 | 'createOrganizationAndAssignToExistingUser failed, error:',
158 | error
159 | );
160 | }
161 | }
162 |
163 | async function assignExistingUserToExistingOrganization({
164 | userId,
165 | organizationId,
166 | }: {
167 | userId: string;
168 | organizationId: string;
169 | }) {
170 | try {
171 | const result = await prisma.organizationUser.create({
172 | data: {
173 | role: 'Member',
174 | organization: {
175 | connect: {
176 | id: organizationId,
177 | },
178 | },
179 | user: {
180 | connect: {
181 | id: userId,
182 | },
183 | },
184 | },
185 | });
186 | console.log(
187 | 'assignExistingUserToExistingOrganization success:',
188 | JSON.stringify(result, null, 2)
189 | );
190 | return result;
191 | } catch (error) {
192 | console.log(
193 | 'assignExistingUserToExistingOrganization failed, error:',
194 | error
195 | );
196 | }
197 | }
198 |
199 | async function findOrganizationAndItsMembers({
200 | organizationId,
201 | }: {
202 | organizationId: string;
203 | }) {
204 | try {
205 | const result = await prisma.organization.findMany({
206 | where: {
207 | id: organizationId,
208 | },
209 | include: {
210 | members: {
211 | include: {
212 | user: true,
213 | },
214 | },
215 | },
216 | });
217 | console.log(
218 | 'findOrganizationAndItsMembers',
219 | JSON.stringify(result, null, 2)
220 | );
221 | return result;
222 | } catch (error) {
223 | console.log('findOrganizationAndItsMembers failed, error:', error);
224 | }
225 | }
226 |
227 | async function findUserAndItsOrganizations({ userId }: { userId: string }) {
228 | try {
229 | const result = await prisma.user.findUnique({
230 | where: {
231 | id: userId,
232 | },
233 | include: {
234 | organizations: {
235 | include: {
236 | organization: true,
237 | },
238 | },
239 | },
240 | });
241 | console.log(
242 | 'findUserAndItsOrganizations success:',
243 | JSON.stringify(result, null, 2)
244 | );
245 | return result;
246 | } catch (error) {
247 | console.log('findUserAndItsOrganizations failed, error:', error);
248 | }
249 | }
250 |
251 | async function main() {
252 | dotenv.config();
253 | console.log('Seeding...');
254 |
255 | emptyTables();
256 | }
257 |
258 | main()
259 | .catch((e) => console.error(e))
260 | .finally(async () => {
261 | await prisma.$disconnect();
262 | });
263 |
--------------------------------------------------------------------------------
/migrations.json:
--------------------------------------------------------------------------------
1 | {
2 | "migrations": [
3 | {
4 | "cli": "nx",
5 | "version": "15.0.0-beta.1",
6 | "description": "Replace implicitDependencies with namedInputs + target inputs",
7 | "implementation": "./src/migrations/update-15-0-0/migrate-to-inputs",
8 | "package": "nx",
9 | "name": "15.0.0-migrate-to-inputs"
10 | },
11 | {
12 | "cli": "nx",
13 | "version": "15.0.0-beta.1",
14 | "description": "Prefix outputs with {workspaceRoot}/{projectRoot} if needed",
15 | "implementation": "./src/migrations/update-15-0-0/prefix-outputs",
16 | "package": "nx",
17 | "name": "15.0.0-prefix-outputs"
18 | },
19 | {
20 | "cli": "nx",
21 | "version": "15.0.12-beta.1",
22 | "description": "Set project names in project.json files",
23 | "implementation": "./src/migrations/update-15-1-0/set-project-names",
24 | "package": "nx",
25 | "name": "15.1.0-set-project-names"
26 | },
27 | {
28 | "cli": "nx",
29 | "version": "14.6.1-beta.0",
30 | "description": "Change Cypress e2e and component testing presets to use __filename instead of __dirname and include a devServerTarget for component testing.",
31 | "factory": "./src/migrations/update-14-6-1/update-cypress-configs-presets",
32 | "package": "@nrwl/cypress",
33 | "name": "update-cypress-configs-preset"
34 | },
35 | {
36 | "cli": "nx",
37 | "version": "14.7.0-beta.0",
38 | "description": "Update Cypress if using v10 to support latest component testing features",
39 | "factory": "./src/migrations/update-14-7-0/update-cypress-version-if-10",
40 | "package": "@nrwl/cypress",
41 | "name": "update-cypress-if-v10"
42 | },
43 | {
44 | "cli": "nx",
45 | "version": "15.0.0-beta.0",
46 | "description": "Stop hashing cypress spec files and config files for build targets and dependent tasks",
47 | "factory": "./src/migrations/update-15-0-0/add-cypress-inputs",
48 | "package": "@nrwl/cypress",
49 | "name": "add-cypress-inputs"
50 | },
51 | {
52 | "cli": "nx",
53 | "version": "15.0.0-beta.4",
54 | "description": "Update to using cy.mount in the commands.ts file instead of importing mount for each component test file",
55 | "factory": "./src/migrations/update-15-0-0/update-cy-mount-usage",
56 | "package": "@nrwl/cypress",
57 | "name": "update-cy-mount-usage"
58 | },
59 | {
60 | "cli": "nx",
61 | "version": "15.1.0-beta.0",
62 | "description": "Update to Cypress v11. This migration will only update if the workspace is already on v10. https://www.cypress.io/blog/2022/11/04/upcoming-changes-to-component-testing/",
63 | "factory": "./src/migrations/update-15-1-0/cypress-11",
64 | "package": "@nrwl/cypress",
65 | "name": "update-to-cypress-11"
66 | },
67 | {
68 | "version": "14.5.5-beta.0",
69 | "cli": "nx",
70 | "description": "Exclude jest.config.ts from tsconfig where missing.",
71 | "factory": "./src/migrations/update-14-0-0/update-jest-config-ext",
72 | "package": "@nrwl/jest",
73 | "name": "exclude-jest-config-from-ts-config"
74 | },
75 | {
76 | "version": "14.6.0-beta.0",
77 | "cli": "nx",
78 | "description": "Update jest configs to support jest 28 changes (https://jestjs.io/docs/upgrading-to-jest28#configuration-options)",
79 | "factory": "./src/migrations/update-14-6-0/update-configs-jest-28",
80 | "package": "@nrwl/jest",
81 | "name": "update-configs-jest-28"
82 | },
83 | {
84 | "version": "14.6.0-beta.0",
85 | "cli": "nx",
86 | "description": "Update jest test files to support jest 28 changes (https://jestjs.io/docs/upgrading-to-jest28)",
87 | "factory": "./src/migrations/update-14-6-0/update-tests-jest-28",
88 | "package": "@nrwl/jest",
89 | "name": "update-tests-jest-28"
90 | },
91 | {
92 | "version": "15.0.0-beta.0",
93 | "cli": "nx",
94 | "description": "Stop hashing jest spec files and config files for build targets and dependent tasks",
95 | "factory": "./src/migrations/update-15-0-0/add-jest-inputs",
96 | "package": "@nrwl/jest",
97 | "name": "add-jest-inputs"
98 | },
99 | {
100 | "cli": "nx",
101 | "version": "14.4.4",
102 | "description": "Adds @typescript-eslint/utils as a dev dep",
103 | "factory": "./src/migrations/update-14-4-4/experimental-to-utils-deps",
104 | "package": "@nrwl/linter",
105 | "name": "experimental-to-utils-deps"
106 | },
107 | {
108 | "cli": "nx",
109 | "version": "14.4.4",
110 | "description": "Switch from @typescript-eslint/experimental-utils to @typescript-eslint/utils in all rules and rules.spec files",
111 | "factory": "./src/migrations/update-14-4-4/experimental-to-utils-rules",
112 | "package": "@nrwl/linter",
113 | "name": "experimental-to-utils-rules"
114 | },
115 | {
116 | "cli": "nx",
117 | "version": "15.0.0-beta.0",
118 | "description": "Stop hashing eslint config files for build targets and dependent tasks",
119 | "factory": "./src/migrations/update-15-0-0/add-eslint-inputs",
120 | "package": "@nrwl/linter",
121 | "name": "add-eslint-inputs"
122 | },
123 | {
124 | "cli": "nx",
125 | "version": "14.7.6-beta.1",
126 | "description": "Update usages of webpack executors to @nrwl/webpack",
127 | "factory": "./src/migrations/update-14-7-6/update-webpack-executor",
128 | "package": "@nrwl/node",
129 | "name": "update-webpack-executor"
130 | },
131 | {
132 | "cli": "nx",
133 | "version": "14.6.0-beta.0",
134 | "description": "Update babel-jest to include the @nrwl/react/babel preset in project jest config",
135 | "factory": "./src/migrations/update-14-6-0/add-preset-jest-config",
136 | "package": "@nrwl/react",
137 | "name": "update-babel-jest-transform-option"
138 | },
139 | {
140 | "cli": "nx",
141 | "version": "15.3.0-beta.0",
142 | "description": "Update projects using @nrwl/web:rollup to @nrwl/rollup:rollup for build.",
143 | "factory": "./src/migrations/update-15-3-0/update-rollup-executor",
144 | "package": "@nrwl/react",
145 | "name": "update-rollup-executor"
146 | },
147 | {
148 | "cli": "nx",
149 | "version": "15.3.0-beta.0",
150 | "description": "Install new dependencies for React projects using Webpack or Rollup.",
151 | "factory": "./src/migrations/update-15-3-0/install-webpack-rollup-dependencies",
152 | "package": "@nrwl/react",
153 | "name": "install-webpack-rollup-dependencies"
154 | },
155 | {
156 | "cli": "nx",
157 | "version": "14.7.6-beta.1",
158 | "description": "Update usages of webpack executors to @nrwl/webpack",
159 | "factory": "./src/migrations/update-14-7-6/update-webpack-executor",
160 | "package": "@nrwl/web",
161 | "name": "update-webpack-executor"
162 | },
163 | {
164 | "cli": "nx",
165 | "version": "15.0.0-beta.0",
166 | "description": "Adds babel.config.json to the hash of all tasks",
167 | "factory": "./src/migrations/update-15-0-0/add-babel-inputs",
168 | "package": "@nrwl/web",
169 | "name": "add-babel-inputs"
170 | },
171 | {
172 | "cli": "nx",
173 | "version": "15.0.0-beta.1",
174 | "description": "Update usages of rollup executors to @nrwl/rollup",
175 | "factory": "./src/migrations/update-15-0-0/update-rollup-executor",
176 | "package": "@nrwl/web",
177 | "name": "update-rollup-executor"
178 | },
179 | {
180 | "version": "14.8.0-beta.0",
181 | "description": "Migrates from @nrwl/workspace:run-commands to nx:run-commands",
182 | "cli": "nx",
183 | "implementation": "./src/migrations/update-14-8-0/change-run-commands-executor",
184 | "package": "@nrwl/workspace",
185 | "name": "14-8-0-change-run-commands-executor"
186 | }
187 | ]
188 | }
189 |
--------------------------------------------------------------------------------
/apps/api/src/auth/auth.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | BadRequestException,
3 | Body,
4 | Controller,
5 | Get,
6 | HttpException,
7 | HttpStatus,
8 | Logger,
9 | Param,
10 | Patch,
11 | Post,
12 | UnauthorizedException,
13 | UseGuards,
14 | } from '@nestjs/common';
15 | import { EMAIL_CONFIRMATION_CODE_LENGTH, Frontend_Routes } from '@fest/shared';
16 | import { ConfigService } from '@nestjs/config';
17 | import * as jwt from 'jsonwebtoken';
18 | import {
19 | addHours,
20 | AuthUser,
21 | EMAIL_CONFIRMATION_CODE_EXPIRY,
22 | transformAuthUserPayload,
23 | ReqUser,
24 | EMAIL_CONFIRMATION_TOKEN_EXPIRY,
25 | } from '../common';
26 | import { MailService } from '../mail/mail.service';
27 | import { emailConfirmation } from '../mail/templates/emailConfirmation';
28 | import { AuthService } from './auth.service';
29 | import { BaseAuthGuard } from '../common';
30 | import { CreateUserDto, UpdateUserDto } from './dto';
31 | import { PrismaService } from '../prisma/prisma.service';
32 |
33 | @Controller('auth')
34 | export class AuthController {
35 | private readonly logger = new Logger(AuthController.name);
36 |
37 | constructor(
38 | private mailService: MailService,
39 | private authService: AuthService,
40 | private config: ConfigService,
41 | private prisma: PrismaService
42 | ) {}
43 |
44 | @Get('me')
45 | @UseGuards(BaseAuthGuard)
46 | async me(@ReqUser() authUser: AuthUser) {
47 | this.logger.log('ReqUser in Me endpoint', authUser);
48 | if (!authUser.email) return null;
49 |
50 | const user = await this.authService.findOneByEmail(authUser.email);
51 | return transformAuthUserPayload(user);
52 | }
53 |
54 | @Post('create-user-based-on-auth0-user')
55 | @UseGuards(BaseAuthGuard)
56 | async createUserBasedOnAuth0User(@Body() createUserDto: CreateUserDto) {
57 | const user = await this.authService.createUserBasedOnAuth0User(
58 | createUserDto
59 | );
60 | return transformAuthUserPayload(user);
61 | }
62 |
63 | @Patch('/update/:id')
64 | @UseGuards(BaseAuthGuard)
65 | async update(
66 | @ReqUser() authUser: AuthUser,
67 | @Param('id') id: string,
68 | @Body() updateUserDto: UpdateUserDto
69 | ) {
70 | if (authUser.id !== id) {
71 | this.logger.error(
72 | 'authUser id is not equal to the user record that needs to be updated'
73 | );
74 | throw new UnauthorizedException('Unauthorized to update the user');
75 | }
76 |
77 | const user = await this.authService.update(id, updateUserDto);
78 | return transformAuthUserPayload(user);
79 | }
80 |
81 | /**
82 | * We allow users to confirm their email address by using one of the two methods:
83 | * 1. By providing confirmation code which they received by email. This code has a short expiry date.
84 | * 2. By clicking a link in their email. The link contains jwt token. The link has longer expiration date.
85 | *
86 | * Hence, we need to generate random string, and sign a jwt token with users email, id and
87 | * send them an email with the code and the link.
88 | */
89 | @Post('send-email-confirmation')
90 | @UseGuards(BaseAuthGuard)
91 | async sendEmailConfirmation(@ReqUser() authUser: AuthUser) {
92 | if (authUser.emailVerified) {
93 | this.logger.warn('Email is already verified');
94 | throw new HttpException(
95 | 'Email is already verified',
96 | HttpStatus.FORBIDDEN
97 | );
98 | }
99 |
100 | try {
101 | // Generate a random code, and its expiration date.
102 | const code = this.authService.generateRandomString(
103 | EMAIL_CONFIRMATION_CODE_LENGTH
104 | );
105 | const codeExpires = addHours(EMAIL_CONFIRMATION_CODE_EXPIRY);
106 |
107 | // Sign a new token with authUser's email and id
108 | const token = jwt.sign(
109 | {
110 | email: authUser.email,
111 | id: authUser.id,
112 | },
113 | this.config.get('EMAIL_CONFIRM_TOKEN_SECRET'),
114 | { expiresIn: EMAIL_CONFIRMATION_TOKEN_EXPIRY }
115 | );
116 |
117 | this.logger.log('Generated random code and jwt token');
118 |
119 | // Send an email to authUser with code and token.
120 | await this.mailService.send({
121 | to: authUser.email,
122 | subject: 'Verify your Fest email',
123 | body: emailConfirmation({
124 | code,
125 | link: `${this.config.get('FRONT_END_URL')}${
126 | Frontend_Routes.GET_STARTED_EMAIL_CONFIRM
127 | }?t=${token}`,
128 | }),
129 | });
130 |
131 | // Update authUser's corresponding db fields
132 | await this.authService.updateUsersEmailVerificationToken(
133 | authUser.id,
134 | token,
135 | code,
136 | codeExpires
137 | );
138 |
139 | this.logger.log(
140 | 'authUsers record has been updated with the token and the code fields'
141 | );
142 |
143 | return { message: 'Email has been sent successfully' };
144 | } catch (error) {
145 | this.logger.error('sendEmailConfirmation has been failed', error);
146 | throw new BadRequestException(error);
147 | }
148 | }
149 |
150 | /**
151 | * Finds the user with email and the code, and checks if the code has expired.
152 | * In case of success, updates the user's emailVerified field to true.
153 | */
154 | @Post('confirm-email-code')
155 | @UseGuards(BaseAuthGuard)
156 | async confirmEmailByCode(@ReqUser() authUser: AuthUser, @Body() body: any) {
157 | const { code } = body;
158 | if (!code) {
159 | this.logger.error('code is not present in the request body');
160 | throw new BadRequestException('Request lacks required params');
161 | }
162 |
163 | const user = await this.authService.findUserByCodeAndEmail(
164 | authUser.email,
165 | code
166 | );
167 |
168 | if (!user) {
169 | this.logger.error(
170 | "Can't find a user record with the given email address and the code",
171 | {
172 | email: authUser.email,
173 | code,
174 | }
175 | );
176 | throw new BadRequestException('Invalid code: User not found');
177 | }
178 |
179 | // Check if code has expired.
180 | const codeExpired = new Date() > user.emailVerificationCodeExpires;
181 | if (codeExpired) {
182 | this.logger.error('The code has been expired');
183 | throw new UnauthorizedException('The code has been expired');
184 | }
185 |
186 | this.logger.log('Email successfully confirmed with the code');
187 |
188 | // Update emailVerified field to true
189 | return this.authService.setEmailVerifiedToTrue(user.id);
190 | }
191 |
192 | /**
193 | * Decodes the jwt token and checks if decoded values (email and id) matches with authUser values.
194 | * If it matches, we search for a record in the db with email and and the token.
195 | * In case of success, updates the user's emailVerified field to true.
196 | */
197 | @Post('confirm-email-token')
198 | @UseGuards(BaseAuthGuard)
199 | async confirmEmailToken(@ReqUser() authUser: AuthUser, @Body() body: any) {
200 | const { token } = body;
201 | if (!token) {
202 | this.logger.error('token is not present in the request body');
203 | throw new BadRequestException('Request lacks required params');
204 | }
205 |
206 | let decoded;
207 | try {
208 | decoded = jwt.verify(
209 | token,
210 | this.config.get('EMAIL_CONFIRM_TOKEN_SECRET')
211 | );
212 | } catch (error) {
213 | this.logger.error('An error detected while decoding the token');
214 | throw new BadRequestException('Invalid token');
215 | }
216 |
217 | if (decoded.email !== authUser.email || decoded.id !== authUser.id) {
218 | this.logger.error(
219 | 'Decoded token does not contain correct data for the authUser'
220 | );
221 | throw new BadRequestException('Invalid token for a user');
222 | }
223 |
224 | const user = await this.authService.findUserByTokenAndEmail(
225 | authUser.email,
226 | token
227 | );
228 |
229 | if (!user) {
230 | this.logger.error(
231 | "Can't find a user record with the given email address and the token",
232 | {
233 | email: authUser.email,
234 | token,
235 | }
236 | );
237 | throw new BadRequestException('User not found');
238 | }
239 |
240 | this.logger.log('Email successfully confirmed with the token');
241 |
242 | return this.authService.setEmailVerifiedToTrue(user.id);
243 | }
244 |
245 | // @ToDo remove me!!!
246 | @Get('delete-all')
247 | async deleteAllTables() {
248 | try {
249 | await this.prisma.organization.deleteMany();
250 | await this.prisma.user.deleteMany();
251 | return 'All tables has been emptied.';
252 | } catch (error) {
253 | this.logger.error('Emptying all tables failed', error);
254 | return error;
255 | }
256 | }
257 | }
258 |
--------------------------------------------------------------------------------
/apps/frontend/pages/styles.css:
--------------------------------------------------------------------------------
1 | html {
2 | -webkit-text-size-adjust: 100%;
3 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
4 | Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif,
5 | Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
6 | line-height: 1.5;
7 | tab-size: 4;
8 | scroll-behavior: smooth;
9 | }
10 | body {
11 | font-family: inherit;
12 | line-height: inherit;
13 | margin: 0;
14 | }
15 | h1,
16 | h2,
17 | p,
18 | pre {
19 | margin: 0;
20 | }
21 | *,
22 | ::before,
23 | ::after {
24 | box-sizing: border-box;
25 | border-width: 0;
26 | border-style: solid;
27 | border-color: currentColor;
28 | }
29 | h1,
30 | h2 {
31 | font-size: inherit;
32 | font-weight: inherit;
33 | }
34 | a {
35 | color: inherit;
36 | text-decoration: inherit;
37 | }
38 | pre {
39 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
40 | Liberation Mono, Courier New, monospace;
41 | }
42 | svg {
43 | display: block;
44 | vertical-align: middle;
45 | shape-rendering: auto;
46 | text-rendering: optimizeLegibility;
47 | }
48 | pre {
49 | background-color: rgba(55, 65, 81, 1);
50 | border-radius: 0.25rem;
51 | color: rgba(229, 231, 235, 1);
52 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
53 | Liberation Mono, Courier New, monospace;
54 | overflow: scroll;
55 | padding: 0.5rem 0.75rem;
56 | }
57 |
58 | .shadow {
59 | box-shadow: 0 0 #0000, 0 0 #0000, 0 10px 15px -3px rgba(0, 0, 0, 0.1),
60 | 0 4px 6px -2px rgba(0, 0, 0, 0.05);
61 | }
62 | .rounded {
63 | border-radius: 1.5rem;
64 | }
65 | .wrapper {
66 | width: 100%;
67 | }
68 | .container {
69 | margin-left: auto;
70 | margin-right: auto;
71 | max-width: 768px;
72 | padding-bottom: 3rem;
73 | padding-left: 1rem;
74 | padding-right: 1rem;
75 | color: rgba(55, 65, 81, 1);
76 | width: 100%;
77 | }
78 | #welcome {
79 | margin-top: 2.5rem;
80 | }
81 | #welcome h1 {
82 | font-size: 3rem;
83 | font-weight: 500;
84 | letter-spacing: -0.025em;
85 | line-height: 1;
86 | }
87 | #welcome span {
88 | display: block;
89 | font-size: 1.875rem;
90 | font-weight: 300;
91 | line-height: 2.25rem;
92 | margin-bottom: 0.5rem;
93 | }
94 | #hero {
95 | align-items: center;
96 | background-color: hsla(214, 62%, 21%, 1);
97 | border: none;
98 | box-sizing: border-box;
99 | color: rgba(55, 65, 81, 1);
100 | display: grid;
101 | grid-template-columns: 1fr;
102 | margin-top: 3.5rem;
103 | }
104 | #hero .text-container {
105 | color: rgba(255, 255, 255, 1);
106 | padding: 3rem 2rem;
107 | }
108 | #hero .text-container h2 {
109 | font-size: 1.5rem;
110 | line-height: 2rem;
111 | position: relative;
112 | }
113 | #hero .text-container h2 svg {
114 | color: hsla(162, 47%, 50%, 1);
115 | height: 2rem;
116 | left: -0.25rem;
117 | position: absolute;
118 | top: 0;
119 | width: 2rem;
120 | }
121 | #hero .text-container h2 span {
122 | margin-left: 2.5rem;
123 | }
124 | #hero .text-container a {
125 | background-color: rgba(255, 255, 255, 1);
126 | border-radius: 0.75rem;
127 | color: rgba(55, 65, 81, 1);
128 | display: inline-block;
129 | margin-top: 1.5rem;
130 | padding: 1rem 2rem;
131 | text-decoration: inherit;
132 | }
133 | #hero .logo-container {
134 | display: none;
135 | justify-content: center;
136 | padding-left: 2rem;
137 | padding-right: 2rem;
138 | }
139 | #hero .logo-container svg {
140 | color: rgba(255, 255, 255, 1);
141 | width: 66.666667%;
142 | }
143 | #middle-content {
144 | align-items: flex-start;
145 | display: grid;
146 | gap: 4rem;
147 | grid-template-columns: 1fr;
148 | margin-top: 3.5rem;
149 | }
150 | #learning-materials {
151 | padding: 2.5rem 2rem;
152 | }
153 | #learning-materials h2 {
154 | font-weight: 500;
155 | font-size: 1.25rem;
156 | letter-spacing: -0.025em;
157 | line-height: 1.75rem;
158 | padding-left: 1rem;
159 | padding-right: 1rem;
160 | }
161 | .list-item-link {
162 | align-items: center;
163 | border-radius: 0.75rem;
164 | display: flex;
165 | margin-top: 1rem;
166 | padding: 1rem;
167 | transition-property: background-color, border-color, color, fill, stroke,
168 | opacity, box-shadow, transform, filter, backdrop-filter,
169 | -webkit-backdrop-filter;
170 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
171 | transition-duration: 150ms;
172 | width: 100%;
173 | }
174 | .list-item-link svg:first-child {
175 | margin-right: 1rem;
176 | height: 1.5rem;
177 | transition-property: background-color, border-color, color, fill, stroke,
178 | opacity, box-shadow, transform, filter, backdrop-filter,
179 | -webkit-backdrop-filter;
180 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
181 | transition-duration: 150ms;
182 | width: 1.5rem;
183 | }
184 | .list-item-link > span {
185 | flex-grow: 1;
186 | font-weight: 400;
187 | transition-property: background-color, border-color, color, fill, stroke,
188 | opacity, box-shadow, transform, filter, backdrop-filter,
189 | -webkit-backdrop-filter;
190 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
191 | transition-duration: 150ms;
192 | }
193 | .list-item-link > span > span {
194 | color: rgba(107, 114, 128, 1);
195 | display: block;
196 | flex-grow: 1;
197 | font-size: 0.75rem;
198 | font-weight: 300;
199 | line-height: 1rem;
200 | transition-property: background-color, border-color, color, fill, stroke,
201 | opacity, box-shadow, transform, filter, backdrop-filter,
202 | -webkit-backdrop-filter;
203 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
204 | transition-duration: 150ms;
205 | }
206 | .list-item-link svg:last-child {
207 | height: 1rem;
208 | transition-property: all;
209 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
210 | transition-duration: 150ms;
211 | width: 1rem;
212 | }
213 | .list-item-link:hover {
214 | color: rgba(255, 255, 255, 1);
215 | background-color: hsla(162, 47%, 50%, 1);
216 | }
217 | .list-item-link:hover > span {
218 | }
219 | .list-item-link:hover > span > span {
220 | color: rgba(243, 244, 246, 1);
221 | }
222 | .list-item-link:hover svg:last-child {
223 | transform: translateX(0.25rem);
224 | }
225 | #other-links {
226 | }
227 | .button-pill {
228 | padding: 1.5rem 2rem;
229 | transition-duration: 300ms;
230 | transition-property: background-color, border-color, color, fill, stroke,
231 | opacity, box-shadow, transform, filter, backdrop-filter,
232 | -webkit-backdrop-filter;
233 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
234 | align-items: center;
235 | display: flex;
236 | }
237 | .button-pill svg {
238 | transition-property: background-color, border-color, color, fill, stroke,
239 | opacity, box-shadow, transform, filter, backdrop-filter,
240 | -webkit-backdrop-filter;
241 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
242 | transition-duration: 150ms;
243 | flex-shrink: 0;
244 | width: 3rem;
245 | }
246 | .button-pill > span {
247 | letter-spacing: -0.025em;
248 | font-weight: 400;
249 | font-size: 1.125rem;
250 | line-height: 1.75rem;
251 | padding-left: 1rem;
252 | padding-right: 1rem;
253 | }
254 | .button-pill span span {
255 | display: block;
256 | font-size: 0.875rem;
257 | font-weight: 300;
258 | line-height: 1.25rem;
259 | }
260 | .button-pill:hover svg,
261 | .button-pill:hover {
262 | color: rgba(255, 255, 255, 1) !important;
263 | }
264 | #nx-console:hover {
265 | background-color: rgba(0, 122, 204, 1);
266 | }
267 | #nx-console svg {
268 | color: rgba(0, 122, 204, 1);
269 | }
270 | #nx-repo:hover {
271 | background-color: rgba(24, 23, 23, 1);
272 | }
273 | #nx-repo svg {
274 | color: rgba(24, 23, 23, 1);
275 | }
276 | #nx-cloud {
277 | margin-bottom: 2rem;
278 | margin-top: 2rem;
279 | padding: 2.5rem 2rem;
280 | }
281 | #nx-cloud > div {
282 | align-items: center;
283 | display: flex;
284 | }
285 | #nx-cloud > div svg {
286 | border-radius: 0.375rem;
287 | flex-shrink: 0;
288 | width: 3rem;
289 | }
290 | #nx-cloud > div h2 {
291 | font-size: 1.125rem;
292 | font-weight: 400;
293 | letter-spacing: -0.025em;
294 | line-height: 1.75rem;
295 | padding-left: 1rem;
296 | padding-right: 1rem;
297 | }
298 | #nx-cloud > div h2 span {
299 | display: block;
300 | font-size: 0.875rem;
301 | font-weight: 300;
302 | line-height: 1.25rem;
303 | }
304 | #nx-cloud p {
305 | font-size: 1rem;
306 | line-height: 1.5rem;
307 | margin-top: 1rem;
308 | }
309 | #nx-cloud pre {
310 | margin-top: 1rem;
311 | }
312 | #nx-cloud a {
313 | color: rgba(107, 114, 128, 1);
314 | display: block;
315 | font-size: 0.875rem;
316 | line-height: 1.25rem;
317 | margin-top: 1.5rem;
318 | text-align: right;
319 | }
320 | #nx-cloud a:hover {
321 | text-decoration: underline;
322 | }
323 | #commands {
324 | padding: 2.5rem 2rem;
325 | margin-top: 3.5rem;
326 | }
327 | #commands h2 {
328 | font-size: 1.25rem;
329 | font-weight: 400;
330 | letter-spacing: -0.025em;
331 | line-height: 1.75rem;
332 | padding-left: 1rem;
333 | padding-right: 1rem;
334 | }
335 | #commands p {
336 | font-size: 1rem;
337 | font-weight: 300;
338 | line-height: 1.5rem;
339 | margin-top: 1rem;
340 | padding-left: 1rem;
341 | padding-right: 1rem;
342 | }
343 | details {
344 | align-items: center;
345 | display: flex;
346 | margin-top: 1rem;
347 | padding-left: 1rem;
348 | padding-right: 1rem;
349 | width: 100%;
350 | }
351 | details pre > span {
352 | color: rgba(181, 181, 181, 1);
353 | display: block;
354 | }
355 | summary {
356 | border-radius: 0.5rem;
357 | display: flex;
358 | font-weight: 400;
359 | padding: 0.5rem;
360 | cursor: pointer;
361 | transition-property: background-color, border-color, color, fill, stroke,
362 | opacity, box-shadow, transform, filter, backdrop-filter,
363 | -webkit-backdrop-filter;
364 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
365 | transition-duration: 150ms;
366 | }
367 | summary:hover {
368 | background-color: rgba(243, 244, 246, 1);
369 | }
370 | summary svg {
371 | height: 1.5rem;
372 | margin-right: 1rem;
373 | width: 1.5rem;
374 | }
375 | #love {
376 | color: rgba(107, 114, 128, 1);
377 | font-size: 0.875rem;
378 | line-height: 1.25rem;
379 | margin-top: 3.5rem;
380 | opacity: 0.6;
381 | text-align: center;
382 | }
383 | #love svg {
384 | color: rgba(252, 165, 165, 1);
385 | width: 1.25rem;
386 | height: 1.25rem;
387 | display: inline;
388 | margin-top: -0.25rem;
389 | }
390 | @media screen and (min-width: 768px) {
391 | #hero {
392 | grid-template-columns: repeat(2, minmax(0, 1fr));
393 | }
394 | #hero .logo-container {
395 | display: flex;
396 | }
397 | #middle-content {
398 | grid-template-columns: repeat(2, minmax(0, 1fr));
399 | }
400 | }
401 |
--------------------------------------------------------------------------------
/apps/frontend/context/AuthContext.tsx:
--------------------------------------------------------------------------------
1 | import { useAuth0 } from '@auth0/auth0-react';
2 | import {
3 | createContext,
4 | useCallback,
5 | useContext,
6 | useEffect,
7 | useState,
8 | } from 'react';
9 | import axios from 'axios';
10 | import {
11 | useAddMemberAfterInvite,
12 | useCreateUserBasedOnAuth0User,
13 | useGetAuthUser,
14 | useUpdateUser,
15 | } from '../services';
16 | import {
17 | AuthProviders,
18 | findAuthProviderFromAuth0Id,
19 | Frontend_Routes,
20 | InvitationTokenDecoded,
21 | } from '@fest/shared';
22 | import { useRouter } from 'next/router';
23 | import { AuthLoading } from '../components/common';
24 |
25 | interface AuthContextProps {
26 | isAuthLoading?: boolean;
27 | isAuthenticated?: boolean;
28 | authError?: string;
29 | auth0Error?: any;
30 | authUser?: any;
31 | setAuthUser?: any;
32 | refetchAuthUser?: any;
33 | setInvitationTokenDecoded?: (decoded: null | InvitationTokenDecoded) => void;
34 | setIsAuthLoading?: (isLoading: boolean) => void;
35 | }
36 |
37 | const AuthContext = createContext({});
38 |
39 | /**
40 | * Links auth0 user with a user in our database.
41 | * In auth0 we only store user's email and password.
42 | * Email verification is done on our end.
43 | */
44 | export const AuthProvider = ({ children }) => {
45 | const router = useRouter();
46 | const [authUser, setAuthUser] = useState(null);
47 | const [authError, setAuthError] = useState('');
48 | const [invitationTokenDecoded, setInvitationTokenDecoded] =
49 | useState(null);
50 | const [isAuthLoading, setIsAuthLoading] = useState(true);
51 | const {
52 | user: auth0User,
53 | error: auth0Error,
54 | isAuthenticated: auth0IsAuthenticated,
55 | isLoading: auth0IsLoading,
56 | getAccessTokenSilently: auth0GetAccessTokenSilently,
57 | } = useAuth0();
58 |
59 | const { refetch: getAuthUser } = useGetAuthUser();
60 | const { mutateAsync: createUserMutation } = useCreateUserBasedOnAuth0User();
61 | const { mutateAsync: updateUserMutation } = useUpdateUser();
62 | const { mutateAsync: addMemberAfterInviteMutation } =
63 | useAddMemberAfterInvite();
64 |
65 | /**
66 | * Re-fetches authUser information from the API and sets it in the state.
67 | */
68 | const refetchAuthUser = useCallback(async () => {
69 | const { data: refreshAuthUser } = await getAuthUser();
70 | console.log('refetchAuthUser', refreshAuthUser);
71 | setAuthUser(refreshAuthUser);
72 | }, [getAuthUser]);
73 |
74 | /**
75 | * Responsible for assigning a user to the organization after accepting the invite
76 | */
77 | useEffect(() => {
78 | if (!invitationTokenDecoded) return;
79 | if (!authUser) return;
80 | if (
81 | authUser.currentOrganization?.id === invitationTokenDecoded.organizationId
82 | ) {
83 | return;
84 | }
85 | // If email from the token isn't equal to the email which the user has used while authenticating
86 | // with auth0, we will not assign a user to the organization.
87 | if (invitationTokenDecoded.email !== authUser.email) {
88 | console.log(
89 | 'Assigning a user to the organization failed due to email mismatch',
90 | authUser.email,
91 | invitationTokenDecoded.email
92 | );
93 | setAuthError(
94 | `Please login with ${invitationTokenDecoded.email} to accept the invitation.`
95 | );
96 | return;
97 | }
98 |
99 | // Add member to the organization in the db, reset invitation token, and refetch the authUser record.
100 | (async () => {
101 | try {
102 | await addMemberAfterInviteMutation({
103 | userId: authUser.id,
104 | organizationId: invitationTokenDecoded.organizationId,
105 | inviterEmail: invitationTokenDecoded.inviterEmail,
106 | });
107 | await refetchAuthUser();
108 | setInvitationTokenDecoded(null);
109 | console.log(
110 | 'Member has been added successfully to the organization, authUser: ',
111 | authUser
112 | );
113 | setIsAuthLoading(false);
114 | router.push(Frontend_Routes.HOME);
115 | } catch (error) {
116 | console.log('Adding member after invite mutation has failed', error);
117 | setIsAuthLoading(false);
118 | }
119 | })();
120 | }, [
121 | invitationTokenDecoded,
122 | authUser,
123 | addMemberAfterInviteMutation,
124 | refetchAuthUser,
125 | router,
126 | ]);
127 |
128 | /**
129 | * Checks if a user is authenticated in auth0
130 | * Gets the accessToken from the auth0 and sets in axios headers
131 | * Gets the user record from our db or creates a new one
132 | */
133 | useEffect(() => {
134 | if (auth0IsLoading) {
135 | console.log('Auth0 Loading...');
136 | return;
137 | }
138 |
139 | if (auth0Error) {
140 | console.log('Auth0 error', auth0Error);
141 | setAuthUser(null);
142 | setIsAuthLoading(false);
143 | return;
144 | }
145 |
146 | if (!auth0IsAuthenticated) {
147 | console.log('Auth0 is not authenticated.');
148 | setAuthUser(null);
149 | setIsAuthLoading(false);
150 | return;
151 | }
152 |
153 | if (!auth0User) {
154 | console.log("Auth0 Can't find user");
155 | setAuthUser(null);
156 | setIsAuthLoading(false);
157 | return;
158 | }
159 |
160 | (async () => {
161 | // Before we send any request with axios, we need to get accessToken auth0 and
162 | // set it into the each request headers, so the API can authorize requests.
163 | // And the baseUrl, so we don't have to write our API url in each request.
164 | axios.interceptors.request.use(
165 | async (config) => {
166 | if (!config.headers['Authorization']) {
167 | const accessToken = await auth0GetAccessTokenSilently();
168 | config.headers['Authorization'] = `Bearer ${accessToken}`;
169 | }
170 | return config;
171 | },
172 | (error) => {
173 | console.log('Setting accessToken in axios interceptor failed', error);
174 | return Promise.reject(error);
175 | }
176 | );
177 |
178 | // Find out authProvider by looking auth0 users id.
179 | // its either email and password from auth0, or social login with Google.
180 | const authProvider: AuthProviders = findAuthProviderFromAuth0Id(
181 | auth0User.sub
182 | );
183 |
184 | // Check if there's a user in our db with the auth0 user's email address.
185 | const { data: existingAuthUser } = await getAuthUser();
186 |
187 | // Generate fullName from the data provided by auth0
188 | const fullName =
189 | auth0User.given_name && auth0User.family_name
190 | ? `${auth0User.given_name} ${auth0User.family_name}`
191 | : auth0User.name;
192 |
193 | // If there isn't, create a new one, based on the values that auth0 provides.
194 | if (!existingAuthUser) {
195 | const userToCreate = {
196 | [`${authProvider}Id`]: auth0User.sub,
197 | email: auth0User.email,
198 | ...(fullName && { fullName }),
199 | ...(auth0User.picture && { avatar: auth0User.picture }),
200 | // If a user has authenticated with Social (Google). It means their email is already verified.
201 | ...(authProvider === AuthProviders.GOOGLE && { emailVerified: true }),
202 | };
203 | const newlyCreatedUser = await createUserMutation(userToCreate);
204 | console.log('Successfully created a new auth user: ', newlyCreatedUser);
205 | setAuthUser(newlyCreatedUser);
206 | setIsAuthLoading(false);
207 | return;
208 | }
209 |
210 | // However, if we've got a record, we need to check if the providers id is defined on their record
211 | // To find out if they have previously logged in with the current provider. If true, we don't need to update their record.
212 | if (existingAuthUser[`${authProvider}Id`]) {
213 | console.log(
214 | `Successfully found auth user with the ${authProvider} provider:`,
215 | existingAuthUser
216 | );
217 | setAuthUser(existingAuthUser);
218 | setIsAuthLoading(false);
219 | return;
220 | }
221 |
222 | // Otherwise we need to update their record to link the new provider.
223 | const updatedUser = await updateUserMutation({
224 | userId: existingAuthUser.id,
225 | fields: {
226 | [`${authProvider}Id`]: auth0User.sub,
227 | ...(!existingAuthUser.fullName && fullName && { fullName }),
228 | ...(!existingAuthUser.picture &&
229 | auth0User.picture && { avatar: auth0User.picture }),
230 | // Edge case, when a user has signed up with email and password, but have not verified their email.
231 | // And then they tried to sign up with social (Google). In this case we need to verify their email
232 | // and reset email confirmation fields.
233 | ...(!existingAuthUser.emailVerified &&
234 | authProvider === AuthProviders.GOOGLE && {
235 | emailVerified: true,
236 | emailVerificationCode: null,
237 | emailVerificationToken: null,
238 | emailVerificationCodeExpires: null,
239 | }),
240 | },
241 | });
242 | console.log('Successfully updated authUser record', updatedUser);
243 | setAuthUser(updatedUser);
244 |
245 | // If invitationTokenDecoded is present then another useEffect responsible for
246 | // assigning a user tot the organization will stop the loading when needed
247 | if (!invitationTokenDecoded) {
248 | setIsAuthLoading(false);
249 | }
250 | })();
251 | }, [
252 | auth0IsAuthenticated,
253 | auth0Error,
254 | auth0IsLoading,
255 | auth0User,
256 | auth0GetAccessTokenSilently,
257 | createUserMutation,
258 | getAuthUser,
259 | updateUserMutation,
260 | invitationTokenDecoded,
261 | ]);
262 |
263 | const renderChildren = () => {
264 | if (isAuthLoading) {
265 | return ;
266 | }
267 |
268 | return auth0Error || children;
269 | };
270 |
271 | return (
272 |
285 | {renderChildren()}
286 |
287 | );
288 | };
289 |
290 | export const useAuth = () => useContext(AuthContext);
291 |
--------------------------------------------------------------------------------
/apps/frontend/pages/get-started/email-confirm.tsx:
--------------------------------------------------------------------------------
1 | import React, { ChangeEvent, useCallback, useEffect, useState } from 'react';
2 | import { withAuthenticationRequired } from '@auth0/auth0-react';
3 | import { useRouter } from 'next/router';
4 | import { Error as ErrorIcon } from '@mui/icons-material';
5 | import {
6 | Stack,
7 | Typography,
8 | TextField,
9 | Box,
10 | InputAdornment,
11 | CircularProgress,
12 | Button,
13 | Alert,
14 | } from '@mui/material';
15 | import { AuthLoading } from '../../components/common';
16 | import {
17 | useSendEmailConfirmation,
18 | useValidateEmailByCode,
19 | useValidateEmailByToken,
20 | } from '../../services';
21 | import { useAuth } from '../../context/AuthContext';
22 | import { EMAIL_CONFIRMATION_CODE_LENGTH, Frontend_Routes } from '@fest/shared';
23 | import { GetStartedLayout } from '../../components/get-started';
24 |
25 | const GetStartedEmailConfirm = () => {
26 | const { isAuthLoading, authUser, refetchAuthUser } = useAuth();
27 | const router = useRouter();
28 |
29 | // For displaying, loading, success and error state, when re-sending the confirmation code.
30 | const [isEmailReSended, setIsEmailReSended] = useState(false);
31 | const [isEmailResendLoading, setIsEmailResendLoading] = useState(false);
32 |
33 | const {
34 | mutateAsync: mutationSendEmailConfirmation,
35 | isSuccess: isSuccessSendEmailConfirmation,
36 | isError: isErrorEmailConfirmation,
37 | } = useSendEmailConfirmation();
38 | const {
39 | mutateAsync: mutationValidateEmailByCode,
40 | isSuccess: isSuccessValidateEmailByCode,
41 | isError: isErrorValidateEmailByCode,
42 | error: errorValidateEmailByCode,
43 | isLoading: isLoadingValidateEmailByCode,
44 | reset: resetValidateEmailByCode,
45 | } = useValidateEmailByCode();
46 | const {
47 | mutateAsync: mutationValidateEmailByToken,
48 | isSuccess: isSuccessValidateEmailByToken,
49 | isError: isErrorValidateEmailByToken,
50 | error: errorValidateEmailByToken,
51 | isLoading: isLoadingValidateEmailByToken,
52 | reset: resetValidateEmailByToken,
53 | } = useValidateEmailByToken();
54 |
55 | /**
56 | * Redirect a user to the home page, if their email address is already verified.
57 | */
58 | useEffect(() => {
59 | if (authUser?.emailVerified) {
60 | console.log(
61 | "authUser's email is already verified, redirecting to the home or organization creation page"
62 | );
63 | authUser?.currentOrganization
64 | ? router.push(Frontend_Routes.HOME)
65 | : router.push(Frontend_Routes.GET_STARTED_ORGANIZATION);
66 | return;
67 | }
68 | }, [authUser?.emailVerified, authUser?.currentOrganization, router]);
69 |
70 | /**
71 | * Helper function to send an email confirmation
72 | * Fired when user clicks on re-send code button or when token is present in the URL's query string
73 | */
74 | const sendEmailConfirmation = useCallback(
75 | async (isFiredManually = false) => {
76 | if (isFiredManually) setIsEmailResendLoading(true);
77 |
78 | await mutationSendEmailConfirmation();
79 | await refetchAuthUser();
80 |
81 | if (isFiredManually) {
82 | setIsEmailResendLoading(false);
83 |
84 | if (!isEmailReSended) setIsEmailReSended(true);
85 | }
86 | },
87 | [mutationSendEmailConfirmation, refetchAuthUser, isEmailReSended]
88 | );
89 |
90 | /**
91 | * Sends an email verification link to the user on the page load, if we haven't sent it already.
92 | */
93 | useEffect(() => {
94 | if (isAuthLoading) return;
95 | if (authUser?.emailVerified) return;
96 | if (authUser?.emailVerificationLinkSent !== false) return;
97 |
98 | (async () => {
99 | console.log('Sending email verification link on the page load.');
100 | sendEmailConfirmation();
101 | })();
102 | }, [
103 | authUser?.emailVerificationLinkSent,
104 | authUser?.emailVerified,
105 | isAuthLoading,
106 | sendEmailConfirmation,
107 | ]);
108 |
109 | /**
110 | * Response handler of email validation with a code.
111 | */
112 | useEffect(() => {
113 | if (isAuthLoading) return;
114 | if (isLoadingValidateEmailByCode) return;
115 | if (authUser?.emailVerified) return;
116 |
117 | (async () => {
118 | if (isSuccessValidateEmailByCode) {
119 | await refetchAuthUser();
120 | console.log('Code validation succeeded and email confirmed');
121 | return;
122 | }
123 | })();
124 | }, [
125 | isAuthLoading,
126 | isSuccessValidateEmailByCode,
127 | refetchAuthUser,
128 | isLoadingValidateEmailByCode,
129 | authUser?.emailVerified,
130 | ]);
131 |
132 | /**
133 | * Response handler of email validation with a token.
134 | */
135 | useEffect(() => {
136 | if (isAuthLoading) return;
137 | if (isLoadingValidateEmailByToken) return;
138 | if (authUser?.emailVerified) return;
139 |
140 | (async () => {
141 | // Refetch authUser and redirect them to the home page if email validation has succeeded.
142 | if (isSuccessValidateEmailByToken) {
143 | await refetchAuthUser();
144 | console.log('Token validation succeeded and email confirmed');
145 | return;
146 | }
147 | })();
148 | }, [
149 | isAuthLoading,
150 | isSuccessValidateEmailByToken,
151 | refetchAuthUser,
152 | isLoadingValidateEmailByToken,
153 | authUser?.emailVerified,
154 | ]);
155 |
156 | /**
157 | * Responsible for validating user's email address if a token is present in the query string.
158 | */
159 | useEffect(() => {
160 | if (isAuthLoading) return;
161 |
162 | const token = router.query?.t as string;
163 | if (!token) return;
164 |
165 | console.log('Email confirm: Found token in the URL');
166 | // Remove token from the URL.
167 | router.replace(Frontend_Routes.GET_STARTED_EMAIL_CONFIRM, undefined, {
168 | shallow: true,
169 | });
170 |
171 | console.log('Validating token: ', token);
172 | mutationValidateEmailByToken(token);
173 | }, [
174 | refetchAuthUser,
175 | isSuccessValidateEmailByToken,
176 | router.query,
177 | router,
178 | mutationValidateEmailByToken,
179 | isAuthLoading,
180 | ]);
181 |
182 | /**
183 | * Checks confirmation code validity when 6 characters are filled out in the input.
184 | */
185 | const onChange = (e: ChangeEvent) => {
186 | e.preventDefault();
187 | const value = e.target.value;
188 |
189 | if (isErrorValidateEmailByCode) resetValidateEmailByCode();
190 | if (isErrorValidateEmailByToken) resetValidateEmailByToken();
191 |
192 | if (value.length === 6) {
193 | mutationValidateEmailByCode(value);
194 | }
195 | };
196 |
197 | /**
198 | * Renders Loading or Error icon in the input field.
199 | */
200 | const renderInputIcon = () => {
201 | if (isLoadingValidateEmailByCode) {
202 | return {
203 | endAdornment: (
204 |
205 |
206 |
207 | ),
208 | };
209 | }
210 |
211 | if (isErrorValidateEmailByCode) {
212 | return {
213 | endAdornment: (
214 |
215 |
216 |
217 | ),
218 | };
219 | }
220 |
221 | return {};
222 | };
223 |
224 | // If the users email is already verified useEffect hook will redirect them to the home page.
225 | if (authUser?.emailVerified) return ;
226 |
227 | return (
228 |
229 | {isLoadingValidateEmailByToken ? (
230 |
231 |
232 | {}
233 |
234 | Confirming email address.
235 |
236 | ) : (
237 | <>
238 |
239 | Confirm Your Email
240 |
241 |
242 |
243 | Enter your confirmation code. We've sent it to{' '}
244 | {authUser.email} .
245 |
246 |
247 |
266 |
267 | {isErrorValidateEmailByToken && (
268 |
269 | {errorValidateEmailByToken?.response?.data?.message}
270 |
271 | )}
272 |
273 |
274 | {!isEmailReSended && !isEmailResendLoading && (
275 | <>
276 |
277 | Can't find the email?
278 |
279 |
280 | sendEmailConfirmation(true)}
283 | >
284 | Resend code
285 |
286 | >
287 | )}
288 |
289 | {isEmailResendLoading && (
290 |
291 | Sending..
292 |
293 | )}
294 |
295 | {isEmailReSended &&
296 | !isEmailResendLoading &&
297 | isSuccessSendEmailConfirmation && (
298 |
299 | Code has been sent successfully.
300 |
301 | )}
302 |
303 | {!isEmailResendLoading && isErrorEmailConfirmation && (
304 |
305 | Failed to send code.
306 |
307 | )}
308 |
309 | >
310 | )}
311 |
312 | );
313 | };
314 |
315 | export default withAuthenticationRequired(GetStartedEmailConfirm);
316 |
--------------------------------------------------------------------------------