Merge branch 'main' into feature/adc/retour-utilisateur

This commit is contained in:
danis
2023-09-22 15:11:33 +02:00
21 changed files with 3764 additions and 3685 deletions

View 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");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "email" DROP NOT NULL;

View File

@@ -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)

View File

@@ -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 })

View File

@@ -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,
@@ -48,15 +49,49 @@ export class AuthService {
to: user.email,
from: 'chromacase@octohub.app',
subject: 'Mail verification for Chromacase',
html: `To verify your mail, please click on this <a href="{${process.env.PUBLIC_URL}/verify?token=${token}">link</a>.`,
html: `To verify your mail, please click on this <a href="${process.env.PUBLIC_URL}/verify?token=${token}">link</a>.`,
});
}
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({

View File

@@ -0,0 +1,8 @@
import { IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class PasswordResetDto {
@ApiProperty()
@IsNotEmpty()
password: string;
}

View File

@@ -10,7 +10,7 @@ import {
} from '@nestjs/common';
import { RequestLogger, RequestLoggerOptions } from 'json-logger-service';
import { tap } from 'rxjs';
import { PrismaModel } from './_gen/prisma-class'
import { PrismaModel } from './_gen/prisma-class';
import { PrismaService } from './prisma/prisma.service';
@Injectable()
@@ -32,15 +32,14 @@ export class AspectLogger implements NestInterceptor {
};
return next.handle().pipe(
tap((data) =>
tap((/* data */) =>
console.log(
JSON.stringify({
...toPrint,
statusCode,
data,
//data, //TODO: Data crashed with images
}),
),
),
),),
);
}
}
@@ -59,7 +58,9 @@ async function bootstrap() {
.setDescription('The chromacase API')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, config, { extraModels: [...PrismaModel.extraModels]});
const document = SwaggerModule.createDocument(app, config, {
extraModels: [...PrismaModel.extraModels],
});
SwaggerModule.setup('api', app, document);
app.useGlobalPipes(new ValidationPipe());

View File

@@ -6,7 +6,7 @@ export class User {
@ApiProperty()
username: string;
@ApiProperty()
email: string;
email: string | null;
@ApiProperty()
isGuest: boolean;
@ApiProperty()

View File

@@ -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');

View File

@@ -77,6 +77,7 @@ services:
depends_on:
- back
- front
- scorometer
volumes:
- "./front/assets:/assets:ro"
- "./front/nginx.conf.template.dev:/etc/nginx/templates/default.conf.template:ro"

View File

@@ -23,6 +23,7 @@ import { AccessTokenResponseHandler } from './models/AccessTokenResponse';
import * as yup from 'yup';
import { base64ToBlob } from './utils/base64ToBlob';
import { ImagePickerAsset } from 'expo-image-picker';
import Constant from 'expo-constants';
type AuthenticationInput = { username: string; password: string };
type RegistrationInput = AuthenticationInput & { email: string };
@@ -68,7 +69,7 @@ export default class API {
public static readonly baseUrl =
process.env.NODE_ENV != 'development' && Platform.OS === 'web'
? '/api'
: 'https://nightly.chroma.octohub.app/api';
: Constant.manifest?.extra?.apiUrl;
public static async fetch(
params: FetchParams,
handle: Pick<Required<HandleParams>, 'raw'>

View File

@@ -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

View 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;

View 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;

View File

@@ -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",

View 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;

View 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;

View File

@@ -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>,
]}

View File

@@ -178,8 +178,8 @@ const SignupView = () => {
try {
const resp = await onSubmit(
formData.username.value,
formData.password.value,
formData.email.value
formData.email.value,
formData.password.value
);
toast.show({ description: resp, colorScheme: 'secondary' });
} catch (e) {

View File

@@ -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>
);

File diff suppressed because it is too large Load Diff