diff --git a/packages/client/src/graphql/email-verification/email-verification.graphql b/packages/client/src/graphql/email-verification/email-verification.graphql new file mode 100644 index 0000000..ce5ebeb --- /dev/null +++ b/packages/client/src/graphql/email-verification/email-verification.graphql @@ -0,0 +1,3 @@ +query getEmailVerificationStatus($accessToken: String!) { + getEmailVerificationStatus(user: {accessToken: $accessToken}) +} \ No newline at end of file diff --git a/packages/client/src/graphql/email-verification/email-verification.ts b/packages/client/src/graphql/email-verification/email-verification.ts new file mode 100644 index 0000000..e0f53db --- /dev/null +++ b/packages/client/src/graphql/email-verification/email-verification.ts @@ -0,0 +1,46 @@ +/* Generated File DO NOT EDIT. */ +/* tslint:disable */ +import * as Types from '../graphql'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type GetEmailVerificationStatusQueryVariables = Types.Exact<{ + accessToken: Types.Scalars['String']; +}>; + +export type GetEmailVerificationStatusQuery = { __typename?: 'Query'; getEmailVerificationStatus: boolean }; + +export const GetEmailVerificationStatusDocument = gql` + query getEmailVerificationStatus($accessToken: String!) { + getEmailVerificationStatus(user: { accessToken: $accessToken }) + } +`; + +/** + * __useGetEmailVerificationStatusQuery__ + * + * To run a query within a React component, call `useGetEmailVerificationStatusQuery` and pass it any options that fit your needs. + * When your component renders, `useGetEmailVerificationStatusQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetEmailVerificationStatusQuery({ + * variables: { + * accessToken: // value for 'accessToken' + * }, + * }); + */ +export function useGetEmailVerificationStatusQuery(baseOptions: Apollo.QueryHookOptions) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useQuery(GetEmailVerificationStatusDocument, options); +} +export function useGetEmailVerificationStatusLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useLazyQuery(GetEmailVerificationStatusDocument, options); +} +export type GetEmailVerificationStatusQueryHookResult = ReturnType; +export type GetEmailVerificationStatusLazyQueryHookResult = ReturnType; +export type GetEmailVerificationStatusQueryResult = Apollo.QueryResult; diff --git a/packages/client/src/graphql/graphql.ts b/packages/client/src/graphql/graphql.ts index e692d6a..80e14e9 100644 --- a/packages/client/src/graphql/graphql.ts +++ b/packages/client/src/graphql/graphql.ts @@ -53,6 +53,10 @@ export type EmailLoginDto = { projectId: Scalars['String']; }; +export type EmailVerificationDto = { + accessToken: Scalars['String']; +}; + export type ForgotDto = { email: Scalars['String']; projectId: Scalars['String']; @@ -193,6 +197,7 @@ export type ProjectCreateInput = { muiTheme?: InputMaybe; name: Scalars['String']; redirectUrl?: InputMaybe; + verifyEmail: Scalars['Boolean']; }; export type ProjectModel = { @@ -215,18 +220,21 @@ export type ProjectModel = { export type ProjectSettingsInput = { allowSignup?: InputMaybe; displayProjectName?: InputMaybe; + verifyEmail: Scalars['Boolean']; }; export type ProjectSettingsModel = { __typename?: 'ProjectSettingsModel'; allowSignup: Scalars['Boolean']; displayProjectName: Scalars['Boolean']; + verifyEmail?: Maybe; }; export type Query = { __typename?: 'Query'; _entities: Array>; _service: _Service; + getEmailVerificationStatus: Scalars['Boolean']; getProject: ProjectModel; getUser: UserModel; invite: InviteModel; @@ -241,6 +249,10 @@ export type Query_EntitiesArgs = { representations: Array; }; +export type QueryGetEmailVerificationStatusArgs = { + user: EmailVerificationDto; +}; + export type QueryGetProjectArgs = { id: Scalars['String']; }; @@ -261,10 +273,6 @@ export type QueryProjectUsersArgs = { projectId: Scalars['String']; }; -export type QueryUsersArgs = { - projectId: Scalars['ID']; -}; - export type ResetDto = { code: Scalars['String']; email: Scalars['String']; @@ -277,6 +285,7 @@ export type UserModel = { createdAt: Scalars['DateTime']; deletedAt?: Maybe; email?: Maybe; + emailVerified?: Maybe; fullname?: Maybe; id: Scalars['ID']; projectId: Scalars['String']; diff --git a/packages/client/src/graphql/project/project.graphql b/packages/client/src/graphql/project/project.graphql index 888f666..b1d2c56 100644 --- a/packages/client/src/graphql/project/project.graphql +++ b/packages/client/src/graphql/project/project.graphql @@ -8,6 +8,7 @@ query getProject($id: String!) { settings { displayProjectName allowSignup + verifyEmail } authMethods { googleAuth diff --git a/packages/client/src/graphql/project/project.ts b/packages/client/src/graphql/project/project.ts index bd440c9..4df9f03 100644 --- a/packages/client/src/graphql/project/project.ts +++ b/packages/client/src/graphql/project/project.ts @@ -18,7 +18,7 @@ export type GetProjectQuery = { logo?: string | null; muiTheme: any; redirectUrl?: string | null; - settings: { __typename?: 'ProjectSettingsModel'; displayProjectName: boolean; allowSignup: boolean }; + settings: { __typename?: 'ProjectSettingsModel'; displayProjectName: boolean; allowSignup: boolean; verifyEmail?: boolean | null }; authMethods: { __typename?: 'ProjectAuthMethodsModel'; googleAuth: boolean; emailAuth: boolean }; }; }; @@ -34,6 +34,7 @@ export const GetProjectDocument = gql` settings { displayProjectName allowSignup + verifyEmail } authMethods { googleAuth diff --git a/packages/client/src/graphql/user/user.graphql b/packages/client/src/graphql/user/user.graphql index ae0a27e..e68a429 100644 --- a/packages/client/src/graphql/user/user.graphql +++ b/packages/client/src/graphql/user/user.graphql @@ -1,5 +1,6 @@ query getUser($id: ID!) { getUser(id: $id) { id + emailVerified } } diff --git a/packages/client/src/graphql/user/user.ts b/packages/client/src/graphql/user/user.ts index 5705466..5c9e06c 100644 --- a/packages/client/src/graphql/user/user.ts +++ b/packages/client/src/graphql/user/user.ts @@ -9,12 +9,13 @@ export type GetUserQueryVariables = Types.Exact<{ id: Types.Scalars['ID']; }>; -export type GetUserQuery = { __typename?: 'Query'; getUser: { __typename?: 'UserModel'; id: string } }; +export type GetUserQuery = { __typename?: 'Query'; getUser: { __typename?: 'UserModel'; id: string; emailVerified?: boolean | null } }; export const GetUserDocument = gql` query getUser($id: ID!) { getUser(id: $id) { id + emailVerified } } `; diff --git a/packages/server/prisma/migrations/20230614175901_added_email_verification_fields_to_db_and_models/migration.sql b/packages/server/prisma/migrations/20230614175901_added_email_verification_fields_to_db_and_models/migration.sql new file mode 100644 index 0000000..1b06091 --- /dev/null +++ b/packages/server/prisma/migrations/20230614175901_added_email_verification_fields_to_db_and_models/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "verifyEmail" BOOLEAN DEFAULT false; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "emailVerified" BOOLEAN DEFAULT false; diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index d0a2c94..7bcc55a 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -27,6 +27,7 @@ model User { Project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) Invite Invite? @relation(name: "acceptedBy") InviteSent Invite[] @relation(name: "invitedBy") + emailVerified Boolean? @default(false) } model Project { @@ -46,6 +47,7 @@ model Project { emailAuth Boolean? @default(true) allowSignup Boolean? @default(true) Invite Invite[] + verifyEmail Boolean? @default(false) } model Invite { diff --git a/packages/server/src/app.module.ts b/packages/server/src/app.module.ts index 3da649a..df57b7d 100644 --- a/packages/server/src/app.module.ts +++ b/packages/server/src/app.module.ts @@ -12,6 +12,7 @@ import { InviteModule } from './invite/invite.module'; import { NotificationModule } from './notification/notification.module'; import { JwtModule } from './jwt/jwt.module'; import { TelemetryModule } from './telemetry/telemetry.module'; +import { EmailVerificationModule } from './email-verification/email-verification.module'; @Module({ imports: [ @@ -30,7 +31,8 @@ import { TelemetryModule } from './telemetry/telemetry.module'; InviteModule, NotificationModule, JwtModule, - TelemetryModule + TelemetryModule, + EmailVerificationModule ] }) export class AppModule implements NestModule { diff --git a/packages/server/src/email-verification/dto/email-verification.dto.ts b/packages/server/src/email-verification/dto/email-verification.dto.ts new file mode 100644 index 0000000..862c611 --- /dev/null +++ b/packages/server/src/email-verification/dto/email-verification.dto.ts @@ -0,0 +1,54 @@ +import { Type } from 'class-transformer'; +import { IsBoolean, IsDefined, IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { PipeTransform } from '@nestjs/common'; +import { Field, InputType } from '@nestjs/graphql'; +import { JwtService } from '@nestjs/jwt'; + +@InputType() +export class EmailVerificationDto { + @IsNotEmpty() + @IsString() + @IsDefined() + @Type(() => String) + @Field() + accessToken: string; +} + +@InputType() +export class GenerateLinkDto { + @IsNotEmpty() + @IsString() + @IsDefined() + @Type(() => String) + @Field() + baseUrl: string; + + @IsNotEmpty() + @IsString() + @IsDefined() + @Type(() => String) + @Field() + accessToken: string; +} + + +export class EmailVerificationTransformPipe implements PipeTransform { + transform(body: any): EmailVerificationDto { + const user = new EmailVerificationDto(); + + user.accessToken = body.accessToken; + + return user; + } +} + +export class SendLinkTransformPipe implements PipeTransform { + transform(body: any): GenerateLinkDto { + const user = new GenerateLinkDto(); + + user.baseUrl = body.baseUrl; + user.accessToken = body.accessToken; + + return user; + } +} diff --git a/packages/server/src/email-verification/email-verification.controller.spec.ts b/packages/server/src/email-verification/email-verification.controller.spec.ts new file mode 100644 index 0000000..e05bcc6 --- /dev/null +++ b/packages/server/src/email-verification/email-verification.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EmailVerificationController } from './email-verification.controller'; + +describe('EmailVerificationController', () => { + let controller: EmailVerificationController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [EmailVerificationController], + }).compile(); + + controller = module.get(EmailVerificationController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/packages/server/src/email-verification/email-verification.controller.ts b/packages/server/src/email-verification/email-verification.controller.ts new file mode 100644 index 0000000..2d6c6e2 --- /dev/null +++ b/packages/server/src/email-verification/email-verification.controller.ts @@ -0,0 +1,26 @@ +import { Controller, ParseUUIDPipe, Get, HttpException, HttpStatus, Param, UsePipes, Body, Put} from '@nestjs/common'; +import { EmailVerificationService } from './email-verification.service'; +import { EmailVerificationDto, EmailVerificationTransformPipe, GenerateLinkDto, SendLinkTransformPipe } from './dto/email-verification.dto'; + +@Controller('email-verification') +export class EmailVerificationController { + constructor(private readonly emailVerificationService: EmailVerificationService) {} + + @Get('status') + @UsePipes(new EmailVerificationTransformPipe()) + async getVerificationStatus(@Body() user: EmailVerificationDto): Promise { + return this.emailVerificationService.getVerificationStatus(user.accessToken) + } + + @Get('verificationLink') + @UsePipes(new SendLinkTransformPipe()) + async generateVerificationLink(@Body() user: GenerateLinkDto): Promise { + return this.emailVerificationService.generateVerificationLink(user.baseUrl, user.accessToken); + } + + @Put('verify') + @UsePipes(new EmailVerificationTransformPipe()) + async verifyEmail(@Body() user: EmailVerificationDto): Promise { + return this.emailVerificationService.verifyEmail(user.accessToken) + } +} diff --git a/packages/server/src/email-verification/email-verification.module.ts b/packages/server/src/email-verification/email-verification.module.ts new file mode 100644 index 0000000..f1d8853 --- /dev/null +++ b/packages/server/src/email-verification/email-verification.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { EmailVerificationController } from './email-verification.controller'; +import { EmailVerificationService } from './email-verification.service'; +import { UserModule } from '../user/user.module'; +import { ProjectModule } from '../project/project.module'; +import { NotificationModule } from '../notification/notification.module'; +import { JwtModule } from '../jwt/jwt.module'; +import { HttpModule } from '@nestjs/axios'; +import { EmailVerificationResolver } from './email-verification.resolver'; +import { PrismaService } from 'src/prisma/prisma.service'; + +@Module({ + imports: [UserModule, ProjectModule, NotificationModule, JwtModule, HttpModule], + controllers: [EmailVerificationController], + providers: [EmailVerificationService, EmailVerificationResolver, PrismaService], + exports: [EmailVerificationService] +}) +export class EmailVerificationModule {} diff --git a/packages/server/src/email-verification/email-verification.resolver.ts b/packages/server/src/email-verification/email-verification.resolver.ts new file mode 100644 index 0000000..9279b41 --- /dev/null +++ b/packages/server/src/email-verification/email-verification.resolver.ts @@ -0,0 +1,35 @@ +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { EmailVerificationService } from './email-verification.service'; + +import { + EmailVerificationDto, + EmailVerificationTransformPipe, + GenerateLinkDto, + SendLinkTransformPipe +} from './dto/email-verification.dto' +import { UsePipes } from '@nestjs/common'; + +@Resolver() +export class EmailVerificationResolver { + constructor(private readonly emailVerificationService: EmailVerificationService) {} + + /**Get User Email Verification Status */ + @Query(() => Boolean) + @UsePipes(new EmailVerificationTransformPipe()) + async getEmailVerificationStatus(@Args('user') user: EmailVerificationDto): Promise { + return this.emailVerificationService.getVerificationStatus(user.accessToken); + } + + @Mutation(() => String) + @UsePipes(new SendLinkTransformPipe()) + async generateVerificationLink(@Args('user') user: GenerateLinkDto): Promise { + return this.emailVerificationService.generateVerificationLink(user.baseUrl, user.accessToken); + } + + @Mutation(() => Boolean) + @UsePipes(new EmailVerificationTransformPipe()) + async verifyEmail(@Args('user') user: EmailVerificationDto): Promise { + return this.emailVerificationService.verifyEmail(user.accessToken); + } + +} \ No newline at end of file diff --git a/packages/server/src/email-verification/email-verification.service.spec.ts b/packages/server/src/email-verification/email-verification.service.spec.ts new file mode 100644 index 0000000..acf9450 --- /dev/null +++ b/packages/server/src/email-verification/email-verification.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EmailVerificationService } from './email-verification.service'; + +describe('EmailVerificationService', () => { + let service: EmailVerificationService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [EmailVerificationService], + }).compile(); + + service = module.get(EmailVerificationService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/packages/server/src/email-verification/email-verification.service.ts b/packages/server/src/email-verification/email-verification.service.ts new file mode 100644 index 0000000..7b729a0 --- /dev/null +++ b/packages/server/src/email-verification/email-verification.service.ts @@ -0,0 +1,47 @@ +import { BadRequestException, HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { UserService } from '../user/user.service'; +import { ProjectService } from '../project/project.service'; +import { NotificationService } from '../notification/notification.service'; +import { HttpService } from '@nestjs/axios'; + +@Injectable() +export class EmailVerificationService { + constructor( + private readonly userService: UserService, + private readonly jwtService: JwtService, + private readonly projectService: ProjectService, + private readonly configService: ConfigService, + private readonly notification: NotificationService, + private readonly http: HttpService + + ) {} + + async getVerificationStatus(accessToken: string): Promise { + const decoded = this.jwtService.decode(accessToken) + + const user = await this.userService.findUserById(decoded['id']); + console.log(user.emailVerified); + return user.emailVerified; + } + + async generateVerificationLink(baseUrl: string, accessToken: string): Promise { + const decoded = this.jwtService.decode(accessToken); + const user = await this.userService.findUserById(decoded['id']); + + const verificationLink = `${baseUrl}?token=${accessToken}`; + return verificationLink; + + } + + async verifyEmail(accessToken: string): Promise { + const decoded = this.jwtService.decode(accessToken) + const user = await this.userService.findUserById(decoded['id']); + + this.userService.updateUserEmailVerificationStatus(user.id, true); + return this.getVerificationStatus(accessToken); + + } + +} diff --git a/packages/server/src/project/dto/project.dto.ts b/packages/server/src/project/dto/project.dto.ts index 6ede76c..0d4ce73 100644 --- a/packages/server/src/project/dto/project.dto.ts +++ b/packages/server/src/project/dto/project.dto.ts @@ -60,6 +60,11 @@ export class ProjectCreateInput implements Omit { @IsNotEmpty() @Field({ nullable: true }) allowSignup: boolean; + + @IsNotEmpty() + @IsDefined() + @Field() + verifyEmail: boolean; } @InputType() diff --git a/packages/server/src/project/model/project-settings.model.ts b/packages/server/src/project/model/project-settings.model.ts index 1b10038..4aefe6c 100644 --- a/packages/server/src/project/model/project-settings.model.ts +++ b/packages/server/src/project/model/project-settings.model.ts @@ -12,4 +12,7 @@ export class ProjectSettingsModel { @Field() allowSignup: boolean; + + @Field({ nullable: true }) + verifyEmail?: boolean; } diff --git a/packages/server/src/user/model/user.model.ts b/packages/server/src/user/model/user.model.ts index d6053e4..5f9b9ff 100644 --- a/packages/server/src/user/model/user.model.ts +++ b/packages/server/src/user/model/user.model.ts @@ -34,4 +34,7 @@ export class UserModel { @Field({ nullable: true }) deletedAt: Date; + + @Field({ nullable: true }) + emailVerified?: boolean; } diff --git a/packages/server/src/user/user.service.ts b/packages/server/src/user/user.service.ts index 9ae74cd..4c35296 100644 --- a/packages/server/src/user/user.service.ts +++ b/packages/server/src/user/user.service.ts @@ -247,4 +247,24 @@ export class UserService { } }); } + + /** + * Update user's email verification status + * + * @param id ID of the user + * @param emailVerified `true` to verify email. `false` to un-verify email + */ + + async updateUserEmailVerificationStatus(id: string, emailVerified: boolean): Promise { + const userToUpdate = await this.findUserById(id); + + await this.prisma.user.update({ + where: { + id: id + }, + data: { + emailVerified: emailVerified + } + }); + } }