diff --git a/back/src/auth/auth.controller.ts b/back/src/auth/auth.controller.ts index ba91edd..bc48297 100644 --- a/back/src/auth/auth.controller.ts +++ b/back/src/auth/auth.controller.ts @@ -18,6 +18,7 @@ import { HttpStatus, ParseFilePipeBuilder, Response, + Query, } from '@nestjs/common'; import { AuthService } from './auth.service'; import { JwtAuthGuard } from './jwt-auth.guard'; @@ -41,7 +42,6 @@ import { SettingsService } from 'src/settings/settings.service'; import { AuthGuard } from '@nestjs/passport'; import { FileInterceptor } from '@nestjs/platform-express'; import { writeFile } from 'fs'; -import { MailerService } from '@nestjs-modules/mailer'; @ApiTags('auth') @Controller('auth') @@ -50,7 +50,6 @@ export class AuthController { private authService: AuthService, private usersService: UsersService, private settingsService: SettingsService, - private emailService: MailerService, ) {} @Get('login/google') @@ -73,19 +72,29 @@ export class AuthController { try { const user = await this.usersService.createUser(registerDto); await this.settingsService.createUserSetting(user.id); - await this.emailService.sendMail({ - to: user.email, - from: "chromacase@octohub.app", - subject: "Mail verification", - text: "To verify your mail click here", - html: "Verify", - }) + await this.authService.sendVerifyMail(user); } catch (e) { console.error(e); throw new BadRequestException(); } } + @HttpCode(200) + @UseGuards(JwtAuthGuard) + @Put('verify') + async verify(@Request() req: any, @Query('token') token: string): Promise { + if (await this.authService.verifyMail(req.user.id, token)) + return; + throw new BadRequestException("Invalid token. Expired or invalid."); + } + + @HttpCode(200) + @UseGuards(JwtAuthGuard) + @Put('reverify') + async reverify(@Request() req: any): Promise { + await this.authService.sendVerifyMail(req.user); + } + @ApiBody({ type: LoginDto }) @HttpCode(200) @UseGuards(LocalAuthGuard) @@ -130,7 +139,7 @@ export class AuthController { ) file: Express.Multer.File, ) { - const path = `/data/${req.user.id}.jpg` + const path = `/data/${req.user.id}.jpg`; writeFile(path, file.buffer, (err) => { if (err) throw err; }); diff --git a/back/src/auth/auth.service.ts b/back/src/auth/auth.service.ts index c733207..56f4679 100644 --- a/back/src/auth/auth.service.ts +++ b/back/src/auth/auth.service.ts @@ -1,13 +1,16 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { UsersService } from '../users/users.service'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcryptjs'; import PayloadInterface from './interface/payload.interface'; +import { User } from 'src/models/user'; +import { MailerService } from '@nestjs-modules/mailer'; @Injectable() export class AuthService { constructor( private userService: UsersService, private jwtService: JwtService, + private emailService: MailerService, ) {} async validateUser( @@ -31,4 +34,33 @@ export class AuthService { access_token, }; } + + async sendVerifyMail(user: User) { + const token = await this.jwtService.signAsync( + { + userId: user.id, + }, + { expiresIn: '10h' }, + ); + await this.emailService.sendMail({ + to: user.email, + from: 'chromacase@octohub.app', + subject: 'Mail verification for Chromacase', + html: `To verify your mail, please click on this link.`, + }); + } + + async verifyMail(userId: number, token: string): Promise { + try { + await this.jwtService.verifyAsync(token); + } catch(e) { + console.log("Verify mail token failure", e); + return false; + } + await this.userService.updateUser({ + where: { id: userId }, + data: { emailVerified: true }, + }); + return true; + } } diff --git a/back/src/users/users.service.ts b/back/src/users/users.service.ts index 55c12b5..62a4a31 100644 --- a/back/src/users/users.service.ts +++ b/back/src/users/users.service.ts @@ -1,8 +1,6 @@ import { Injectable, InternalServerErrorException, - NotFoundException, - StreamableFile, } from '@nestjs/common'; import { User, Prisma } from '@prisma/client'; import { PrismaService } from 'src/prisma/prisma.service'; @@ -13,7 +11,9 @@ import fetch from 'node-fetch'; @Injectable() export class UsersService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + ) {} async user( userWhereUniqueInput: Prisma.UserWhereUniqueInput, @@ -95,8 +95,7 @@ export class UsersService { const resp = await fetch( `https://www.gravatar.com/avatar/${hash}.jpg?d=404&s=200`, ); - for (const [k, v] of resp.headers) - resp.headers.set(k, v); + for (const [k, v] of resp.headers) resp.headers.set(k, v); resp.body!.pipe(res); } } diff --git a/front/Navigation.tsx b/front/Navigation.tsx index 1e12973..486ac99 100644 --- a/front/Navigation.tsx +++ b/front/Navigation.tsx @@ -30,6 +30,7 @@ import TextButton from './components/TextButton'; import ErrorView from './views/ErrorView'; import GenreDetailsView from './views/GenreDetailsView'; import GoogleView from './views/GoogleView'; +import VerifiedView from './views/VerifiedView'; // Util function to hide route props in URL const removeMe = () => ''; @@ -81,6 +82,11 @@ const protectedRoutes = () => link: undefined, }, User: { component: ProfileView, options: { title: translate('user') }, link: '/user' }, + Verified: { + component: VerifiedView, + options: { title: 'Verify email', headerShown: false }, + link: '/verify', + }, } as const); const publicRoutes = () => diff --git a/front/models/User.ts b/front/models/User.ts index 1f85df2..a53a110 100644 --- a/front/models/User.ts +++ b/front/models/User.ts @@ -8,6 +8,7 @@ export const UserValidator = yup username: yup.string().required(), password: yup.string().required().nullable(), email: yup.string().required(), + emailVerified: yup.boolean().required(), googleID: yup.string().required().nullable(), isGuest: yup.boolean().required(), partyPlayed: yup.number().required(), @@ -32,6 +33,7 @@ export const UserHandler: ResponseHandler, U interface User extends Model { name: string; email: string; + emailVerified: boolean; googleID: string | null; isGuest: boolean; premium: boolean; diff --git a/front/views/VerifiedView.tsx b/front/views/VerifiedView.tsx new file mode 100644 index 0000000..37286c7 --- /dev/null +++ b/front/views/VerifiedView.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import API from '../API'; +import { Text } from 'native-base'; +import { useNavigation } from '../Navigation'; +import { useRoute } from '@react-navigation/native'; + +const VerifiedView = () => { + const navigation = useNavigation(); + const route = useRoute(); + const [failed, setFailed] = useState(false); + + useEffect(() => { + async function run() { + try { + await API.fetch({ + route: `/auth/verify?token=${(route.params as any).token}`, + method: 'PUT', + }); + navigation.navigate('Home'); + } catch { + setFailed(true); + } + } + run(); + }, []); + + return failed ? ( + Email verification failed. The token has expired or is invalid. + ) : ( + Loading please wait + ); +}; + +export default VerifiedView; diff --git a/front/views/settings/SettingsProfileView.tsx b/front/views/settings/SettingsProfileView.tsx index bf946f4..bd4b199 100644 --- a/front/views/settings/SettingsProfileView.tsx +++ b/front/views/settings/SettingsProfileView.tsx @@ -49,7 +49,7 @@ const ProfileSettings = ({ navigation }: { navigation: any }) => { type: 'text', title: translate('email'), data: { - text: user.email || translate('NoAssociatedEmail'), + text: `${user.email} ${user.emailVerified ? "verified" : "not verified"}` || translate('NoAssociatedEmail'), onPress: () => { navigation.navigate('changeEmail'); },