feat: back password reset email (#277)
This commit is contained in:
8
back/prisma/migrations/20230920151856_/migration.sql
Normal file
8
back/prisma/migrations/20230920151856_/migration.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[email]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
2
back/prisma/migrations/20230921103156_/migration.sql
Normal file
2
back/prisma/migrations/20230921103156_/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "email" DROP NOT NULL;
|
||||
@@ -19,7 +19,7 @@ model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
password String?
|
||||
email String
|
||||
email String? @unique
|
||||
emailVerified Boolean @default(false)
|
||||
googleID String? @unique
|
||||
isGuest Boolean @default(false)
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
HttpStatus,
|
||||
ParseFilePipeBuilder,
|
||||
Response,
|
||||
Param,
|
||||
Query,
|
||||
Param,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
@@ -50,6 +50,7 @@ import { SettingsService } from 'src/settings/settings.service';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { writeFile } from 'fs';
|
||||
import { PasswordResetDto } from './dto/password_reset.dto ';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@@ -115,11 +116,31 @@ export class AuthController {
|
||||
@ApiOperation({description: 'Resend the verification email'})
|
||||
async reverify(@Request() req: any): Promise<void> {
|
||||
const user = await this.usersService.user({ id: req.user.id });
|
||||
if (!user) throw new BadRequestException("Invalid user");
|
||||
if (!user) throw new BadRequestException('Invalid user');
|
||||
await this.authService.sendVerifyMail(user);
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@Put('password-reset')
|
||||
async password_reset(
|
||||
@Body() resetDto: PasswordResetDto,
|
||||
@Query('token') token: string,
|
||||
): Promise<void> {
|
||||
if (await this.authService.changePassword(resetDto.password, token)) return;
|
||||
throw new BadRequestException('Invalid token. Expired or invalid.');
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@Put('forgot-password')
|
||||
async forgot_password(@Query('email') email: string): Promise<void> {
|
||||
console.log(email);
|
||||
const user = await this.usersService.user({ email: email });
|
||||
if (!user) throw new BadRequestException('Invalid user');
|
||||
await this.authService.sendPasswordResetMail(user);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@ApiBody({ type: LoginDto })
|
||||
@HttpCode(200)
|
||||
@UseGuards(LocalAuthGuard)
|
||||
@ApiBody({ type: LoginDto })
|
||||
|
||||
@@ -36,8 +36,9 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async sendVerifyMail(user: User) {
|
||||
if (process.env.IGNORE_MAILS === "true") return;
|
||||
console.log("Sending verification mail to", user.email);
|
||||
if (process.env.IGNORE_MAILS === 'true') return;
|
||||
if (user.email == null) return;
|
||||
console.log('Sending verification mail to', user.email);
|
||||
const token = await this.jwtService.signAsync(
|
||||
{
|
||||
userId: user.id,
|
||||
@@ -52,11 +53,45 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async sendPasswordResetMail(user: User) {
|
||||
if (process.env.IGNORE_MAILS === 'true') return;
|
||||
if (user.email == null) return;
|
||||
console.log('Sending password reset mail to', user.email);
|
||||
const token = await this.jwtService.signAsync(
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{ expiresIn: '10h' },
|
||||
);
|
||||
await this.emailService.sendMail({
|
||||
to: user.email,
|
||||
from: 'chromacase@octohub.app',
|
||||
subject: 'Password reset for Chromacase',
|
||||
html: `To reset your password, please click on this <a href="{${process.env.PUBLIC_URL}/password_reset?token=${token}">link</a>.`,
|
||||
});
|
||||
}
|
||||
|
||||
async changePassword(new_password: string, token: string): Promise<boolean> {
|
||||
let verified;
|
||||
try {
|
||||
verified = await this.jwtService.verifyAsync(token);
|
||||
} catch (e) {
|
||||
console.log('Password reset token failure', e);
|
||||
return false;
|
||||
}
|
||||
console.log(verified)
|
||||
await this.userService.updateUser({
|
||||
where: { id: verified.userId },
|
||||
data: { password: new_password },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async verifyMail(userId: number, token: string): Promise<boolean> {
|
||||
try {
|
||||
await this.jwtService.verifyAsync(token);
|
||||
} catch(e) {
|
||||
console.log("Verify mail token failure", e);
|
||||
} catch (e) {
|
||||
console.log('Verify mail token failure', e);
|
||||
return false;
|
||||
}
|
||||
await this.userService.updateUser({
|
||||
|
||||
8
back/src/auth/dto/password_reset.dto .ts
Normal file
8
back/src/auth/dto/password_reset.dto .ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class PasswordResetDto {
|
||||
@ApiProperty()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export class User {
|
||||
@ApiProperty()
|
||||
username: string;
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
email: string | null;
|
||||
@ApiProperty()
|
||||
isGuest: boolean;
|
||||
@ApiProperty()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { User, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
@@ -53,7 +54,7 @@ export class UsersService {
|
||||
username: `Guest ${randomUUID()}`,
|
||||
isGuest: true,
|
||||
// Not realyl clean but better than a separate table or breaking the api by adding nulls.
|
||||
email: '',
|
||||
email: null,
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
@@ -89,6 +90,7 @@ export class UsersService {
|
||||
// We could not find a profile icon locally, using gravatar instead.
|
||||
const user = await this.user({ id: userId });
|
||||
if (!user) throw new InternalServerErrorException();
|
||||
if (!user.email) throw new NotFoundException();
|
||||
const hash = createHash('md5')
|
||||
.update(user.email.trim().toLowerCase())
|
||||
.digest('hex');
|
||||
|
||||
@@ -33,6 +33,8 @@ import VerifiedView from './views/VerifiedView';
|
||||
import SigninView from './views/SigninView';
|
||||
import SignupView from './views/SignupView';
|
||||
import TabNavigation from './components/V2/TabNavigation';
|
||||
import PasswordResetView from './views/PasswordResetView';
|
||||
import ForgotPasswordView from './views/ForgotPasswordView';
|
||||
|
||||
// Util function to hide route props in URL
|
||||
const removeMe = () => '';
|
||||
@@ -123,6 +125,16 @@ const publicRoutes = () =>
|
||||
options: { title: 'Google signin', headerShown: false },
|
||||
link: '/logged/google',
|
||||
},
|
||||
PasswordReset: {
|
||||
component: PasswordResetView,
|
||||
options: { title: 'Password reset form', headerShown: false },
|
||||
link: '/password_reset',
|
||||
},
|
||||
ForgotPassword: {
|
||||
component: ForgotPasswordView,
|
||||
options: { title: 'Password reset form', headerShown: false },
|
||||
link: '/forgot_password',
|
||||
},
|
||||
} as const);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
73
front/components/forms/forgotPasswordForm.tsx
Normal file
73
front/components/forms/forgotPasswordForm.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { translate } from '../../i18n/i18n';
|
||||
import { string } from 'yup';
|
||||
import { FormControl, Input, Stack, WarningOutlineIcon, Box, Button, useToast } from 'native-base';
|
||||
|
||||
interface ForgotPasswordFormProps {
|
||||
onSubmit: (email: string) => Promise<string>;
|
||||
}
|
||||
|
||||
const validationSchemas = {
|
||||
email: string().email('Invalid email').required('Email is required'),
|
||||
};
|
||||
|
||||
const ForgotPasswordForm = ({ onSubmit }: ForgotPasswordFormProps) => {
|
||||
const [formData, setFormData] = React.useState({
|
||||
newEmail: {
|
||||
value: '',
|
||||
error: null as string | null,
|
||||
},
|
||||
});
|
||||
|
||||
const [submittingForm, setSubmittingForm] = React.useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack mx="4" style={{ width: '80%', maxWidth: 400 }}>
|
||||
<FormControl isRequired isInvalid={formData.newEmail.error !== null}>
|
||||
<FormControl.Label>{translate('newEmail')}</FormControl.Label>
|
||||
<Input
|
||||
isRequired
|
||||
type="text"
|
||||
placeholder={translate('newEmail')}
|
||||
value={formData.newEmail.value}
|
||||
onChangeText={(t) => {
|
||||
let error: null | string = null;
|
||||
validationSchemas.email
|
||||
.validate(t)
|
||||
.catch((e) => (error = e.message))
|
||||
.finally(() => {
|
||||
setFormData({ ...formData, newEmail: { value: t, error } });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<FormControl.ErrorMessage leftIcon={<WarningOutlineIcon size="xs" />}>
|
||||
{formData.newEmail.error}
|
||||
</FormControl.ErrorMessage>
|
||||
|
||||
<Button
|
||||
style={{ marginTop: 10 }}
|
||||
isLoading={submittingForm}
|
||||
isDisabled={formData.newEmail.error !== null}
|
||||
onPress={async () => {
|
||||
setSubmittingForm(true);
|
||||
try {
|
||||
const resp = await onSubmit(formData.newEmail.value);
|
||||
toast.show({ description: resp });
|
||||
} catch (e) {
|
||||
toast.show({ description: e as string });
|
||||
} finally {
|
||||
setSubmittingForm(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{translate('submitBtn')}
|
||||
</Button>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPasswordForm;
|
||||
114
front/components/forms/passwordResetForm.tsx
Normal file
114
front/components/forms/passwordResetForm.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { translate } from '../../i18n/i18n';
|
||||
import { string } from 'yup';
|
||||
import { FormControl, Input, Stack, WarningOutlineIcon, Box, Button, useToast } from 'native-base';
|
||||
|
||||
interface PasswordResetFormProps {
|
||||
onSubmit: (newPassword: string) => Promise<string>;
|
||||
}
|
||||
|
||||
const PasswordResetForm = ({ onSubmit }: PasswordResetFormProps) => {
|
||||
const [formData, setFormData] = React.useState({
|
||||
newPassword: {
|
||||
value: '',
|
||||
error: null as string | null,
|
||||
},
|
||||
confirmNewPassword: {
|
||||
value: '',
|
||||
error: null as string | null,
|
||||
},
|
||||
});
|
||||
const [submittingForm, setSubmittingForm] = React.useState(false);
|
||||
|
||||
const validationSchemas = {
|
||||
password: string()
|
||||
.min(4, translate('passwordTooShort'))
|
||||
.max(100, translate('passwordTooLong'))
|
||||
.required('Password is required'),
|
||||
};
|
||||
const toast = useToast();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack mx="4" style={{ width: '80%', maxWidth: 400 }}>
|
||||
<FormControl
|
||||
isRequired
|
||||
isInvalid={
|
||||
formData.newPassword.error !== null ||
|
||||
formData.confirmNewPassword.error !== null
|
||||
}
|
||||
>
|
||||
<FormControl.Label>{translate('newPassword')}</FormControl.Label>
|
||||
<Input
|
||||
isRequired
|
||||
type="password"
|
||||
placeholder={translate('newPassword')}
|
||||
value={formData.newPassword.value}
|
||||
onChangeText={(t) => {
|
||||
let error: null | string = null;
|
||||
validationSchemas.password
|
||||
.validate(t)
|
||||
.catch((e) => (error = e.message))
|
||||
.finally(() => {
|
||||
setFormData({ ...formData, newPassword: { value: t, error } });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<FormControl.ErrorMessage leftIcon={<WarningOutlineIcon size="xs" />}>
|
||||
{formData.newPassword.error}
|
||||
</FormControl.ErrorMessage>
|
||||
|
||||
<FormControl.Label>{translate('confirmNewPassword')}</FormControl.Label>
|
||||
<Input
|
||||
isRequired
|
||||
type="password"
|
||||
placeholder={translate('confirmNewPassword')}
|
||||
value={formData.confirmNewPassword.value}
|
||||
onChangeText={(t) => {
|
||||
let error: null | string = null;
|
||||
validationSchemas.password
|
||||
.validate(t)
|
||||
.catch((e) => (error = e.message));
|
||||
if (!error && t !== formData.newPassword.value) {
|
||||
error = translate('passwordsDontMatch');
|
||||
}
|
||||
setFormData({
|
||||
...formData,
|
||||
confirmNewPassword: { value: t, error },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<FormControl.ErrorMessage leftIcon={<WarningOutlineIcon size="xs" />}>
|
||||
{formData.confirmNewPassword.error}
|
||||
</FormControl.ErrorMessage>
|
||||
|
||||
<Button
|
||||
style={{ marginTop: 10 }}
|
||||
isLoading={submittingForm}
|
||||
isDisabled={
|
||||
formData.newPassword.error !== null ||
|
||||
formData.confirmNewPassword.error !== null ||
|
||||
formData.newPassword.value === '' ||
|
||||
formData.confirmNewPassword.value === ''
|
||||
}
|
||||
onPress={async () => {
|
||||
setSubmittingForm(true);
|
||||
try {
|
||||
const resp = await onSubmit(formData.newPassword.value);
|
||||
toast.show({ description: resp });
|
||||
} catch (e) {
|
||||
toast.show({ description: e as string });
|
||||
} finally {
|
||||
setSubmittingForm(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{translate('submitBtn')}
|
||||
</Button>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordResetForm;
|
||||
@@ -54,7 +54,7 @@
|
||||
"native-base": "^3.4.17",
|
||||
"opensheetmusicdisplay": "^1.7.5",
|
||||
"phaser": "^3.60.0",
|
||||
"react": "18.1.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.1.0",
|
||||
"react-i18next": "^11.18.3",
|
||||
"react-native": "0.70.5",
|
||||
@@ -91,8 +91,8 @@
|
||||
"@storybook/testing-library": "^0.0.13",
|
||||
"@testing-library/react-native": "^11.0.0",
|
||||
"@types/node": "^18.11.8",
|
||||
"@types/react": "~18.0.24",
|
||||
"@types/react-native": "~0.70.6",
|
||||
"@types/react": "~18.2.0",
|
||||
"@types/react-native": "~0.70.5",
|
||||
"@types/react-navigation": "^3.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.43.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
|
||||
28
front/views/ForgotPasswordView.tsx
Normal file
28
front/views/ForgotPasswordView.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import API from '../API';
|
||||
import { useNavigation } from '../Navigation';
|
||||
import ForgotPasswordForm from '../components/forms/forgotPasswordForm';
|
||||
|
||||
const ForgotPasswordView = () => {
|
||||
const navigation = useNavigation();
|
||||
|
||||
async function handleSubmit(email: string) {
|
||||
try {
|
||||
await API.fetch({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
route: `/auth/forgot-password?email=${email}`,
|
||||
method: 'PUT',
|
||||
});
|
||||
navigation.navigate('Home');
|
||||
return 'email sent';
|
||||
} catch {
|
||||
return 'Error with email, please contact support';
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<ForgotPasswordForm onSubmit={handleSubmit} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPasswordView;
|
||||
34
front/views/PasswordResetView.tsx
Normal file
34
front/views/PasswordResetView.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import API from '../API';
|
||||
import { useNavigation } from '../Navigation';
|
||||
import { useRoute } from '@react-navigation/native';
|
||||
import PasswordResetForm from '../components/forms/passwordResetForm';
|
||||
|
||||
const PasswordResetView = () => {
|
||||
const navigation = useNavigation();
|
||||
const route = useRoute();
|
||||
|
||||
const handlePasswordReset = async (password: string) => {
|
||||
try {
|
||||
await API.fetch({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
route: `/auth/password-reset?token=${(route.params as any).token}`,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
password,
|
||||
},
|
||||
});
|
||||
navigation.navigate('Home');
|
||||
return 'password succesfully reset';
|
||||
} catch {
|
||||
return 'password reset failed';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PasswordResetForm onSubmit={(password) => handlePasswordReset(password)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordResetView;
|
||||
@@ -100,7 +100,7 @@ const SigninView = () => {
|
||||
}}
|
||||
isRequired
|
||||
/>,
|
||||
<LinkBase key={'signin-link'} onPress={() => console.log('Link clicked!')}>
|
||||
<LinkBase key={'signin-link'} onPress={() => navigation.navigate('ForgotPassword')}>
|
||||
{translate('forgottenPassword')}
|
||||
</LinkBase>,
|
||||
]}
|
||||
|
||||
@@ -178,6 +178,23 @@ const StartPageView = () => {
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
width: '90%',
|
||||
marginTop: 20,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Link href="/forgot_password">I forgot my password</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
</Column>
|
||||
</View>
|
||||
);
|
||||
|
||||
7046
front/yarn.lock
7046
front/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user