diff --git a/confiture-rest-api/package.json b/confiture-rest-api/package.json index c29fccb5..02cf9e25 100644 --- a/confiture-rest-api/package.json +++ b/confiture-rest-api/package.json @@ -29,18 +29,18 @@ "@nestjs/common": "^9.4.0", "@nestjs/config": "^2.3.1", "@nestjs/core": "^9.4.0", + "@nestjs/jwt": "^10.0.3", "@nestjs/platform-express": "^9.4.0", "@nestjs/swagger": "^6.3.0", "@prisma/client": "^4.1.1", - "@types/lodash": "^4.14.194", "@vegardit/prisma-generator-nestjs-dto": "^1.5.1", + "bcrypt": "^5.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.13.2", "got": "^11.8.5", "handlebars": "^4.7.7", "joi": "^17.6.0", "lodash": "^4.17.21", - "lodash-es": "^4.17.21", "mjml": "^4.14.1", "morgan": "^1.10.0", "nanoid": "^3.3.4", @@ -56,9 +56,10 @@ "@nestjs/cli": "^9.1.8", "@nestjs/schematics": "^9.0.4", "@nestjs/testing": "^9.2.1", + "@types/bcrypt": "^5.0.0", "@types/express": "^4.17.13", "@types/jest": "27.4.1", - "@types/lodash-es": "^4.17.7", + "@types/lodash": "^4.14.194", "@types/mjml": "^4.7.1", "@types/multer": "^1.4.7", "@types/node": "^16.0.0", diff --git a/confiture-rest-api/prisma/migrations/20230421125740_add_user_model/migration.sql b/confiture-rest-api/prisma/migrations/20230421125740_add_user_model/migration.sql new file mode 100644 index 00000000..7bc50c19 --- /dev/null +++ b/confiture-rest-api/prisma/migrations/20230421125740_add_user_model/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "username" TEXT NOT NULL, + "password" TEXT NOT NULL, + "isVerified" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/confiture-rest-api/prisma/migrations/20230421133353_add_account_verification_email_type/migration.sql b/confiture-rest-api/prisma/migrations/20230421133353_add_account_verification_email_type/migration.sql new file mode 100644 index 00000000..46d0abe4 --- /dev/null +++ b/confiture-rest-api/prisma/migrations/20230421133353_add_account_verification_email_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "EmailType" ADD VALUE 'ACCOUNT_VERIFICATION'; diff --git a/confiture-rest-api/prisma/migrations/20230515120156_add_veritication_jti_field/migration.sql b/confiture-rest-api/prisma/migrations/20230515120156_add_veritication_jti_field/migration.sql new file mode 100644 index 00000000..5d3a2bdf --- /dev/null +++ b/confiture-rest-api/prisma/migrations/20230515120156_add_veritication_jti_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "verificationJti" TEXT; diff --git a/confiture-rest-api/prisma/migrations/20230517091530_add_account_confirmation_email_type/migration.sql b/confiture-rest-api/prisma/migrations/20230517091530_add_account_confirmation_email_type/migration.sql new file mode 100644 index 00000000..08575b97 --- /dev/null +++ b/confiture-rest-api/prisma/migrations/20230517091530_add_account_confirmation_email_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "EmailType" ADD VALUE 'ACCOUNT_CONFIRMATION'; diff --git a/confiture-rest-api/prisma/migrations/20230517123957_add_user_uid_field/migration.sql b/confiture-rest-api/prisma/migrations/20230517123957_add_user_uid_field/migration.sql new file mode 100644 index 00000000..ae71c92f --- /dev/null +++ b/confiture-rest-api/prisma/migrations/20230517123957_add_user_uid_field/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - A unique constraint covering the columns `[uid]` on the table `User` will be added. If there are existing duplicate values, this will fail. + - The required column `uid` was added to the `User` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. + +*/ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "uid" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "User_uid_key" ON "User"("uid"); diff --git a/confiture-rest-api/prisma/migrations/20230609142957_add_name_and_orgname_to_user_model/migration.sql b/confiture-rest-api/prisma/migrations/20230609142957_add_name_and_orgname_to_user_model/migration.sql new file mode 100644 index 00000000..be7d8e80 --- /dev/null +++ b/confiture-rest-api/prisma/migrations/20230609142957_add_name_and_orgname_to_user_model/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "name" TEXT, +ADD COLUMN "orgName" TEXT; diff --git a/confiture-rest-api/prisma/migrations/20230623071020_add_active_feedback_token_model/migration.sql b/confiture-rest-api/prisma/migrations/20230623071020_add_active_feedback_token_model/migration.sql new file mode 100644 index 00000000..37135bad --- /dev/null +++ b/confiture-rest-api/prisma/migrations/20230623071020_add_active_feedback_token_model/migration.sql @@ -0,0 +1,6 @@ +-- CreateTable +CREATE TABLE "ActiveFeedbackToken" ( + "uid" TEXT NOT NULL, + + CONSTRAINT "ActiveFeedbackToken_pkey" PRIMARY KEY ("uid") +); diff --git a/confiture-rest-api/prisma/migrations/20230627131238_make_auditor_email_nullable/migration.sql b/confiture-rest-api/prisma/migrations/20230627131238_make_auditor_email_nullable/migration.sql new file mode 100644 index 00000000..7f6ba25f --- /dev/null +++ b/confiture-rest-api/prisma/migrations/20230627131238_make_auditor_email_nullable/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Audit" ALTER COLUMN "auditorEmail" DROP NOT NULL; diff --git a/confiture-rest-api/prisma/migrations/20230630142347_add_password_confirmation_email_type/migration.sql b/confiture-rest-api/prisma/migrations/20230630142347_add_password_confirmation_email_type/migration.sql new file mode 100644 index 00000000..9403777b --- /dev/null +++ b/confiture-rest-api/prisma/migrations/20230630142347_add_password_confirmation_email_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "EmailType" ADD VALUE 'PASSWORD_UPDATE_CONFIRMATION'; diff --git a/confiture-rest-api/prisma/migrations/20230915132620_add_new_email_field/migration.sql b/confiture-rest-api/prisma/migrations/20230915132620_add_new_email_field/migration.sql new file mode 100644 index 00000000..8d21c029 --- /dev/null +++ b/confiture-rest-api/prisma/migrations/20230915132620_add_new_email_field/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "newEmail" TEXT, +ADD COLUMN "newEmailVerificationJti" TEXT; diff --git a/confiture-rest-api/prisma/migrations/20230915140227_add_new_email_verification_type/migration.sql b/confiture-rest-api/prisma/migrations/20230915140227_add_new_email_verification_type/migration.sql new file mode 100644 index 00000000..7813336f --- /dev/null +++ b/confiture-rest-api/prisma/migrations/20230915140227_add_new_email_verification_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "EmailType" ADD VALUE 'EMAIL_UPDATE_VERIFICATION'; diff --git a/confiture-rest-api/prisma/migrations/20230922141437_add_email_update_confirmation_email_type/migration.sql b/confiture-rest-api/prisma/migrations/20230922141437_add_email_update_confirmation_email_type/migration.sql new file mode 100644 index 00000000..1db1efa8 --- /dev/null +++ b/confiture-rest-api/prisma/migrations/20230922141437_add_email_update_confirmation_email_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "EmailType" ADD VALUE 'EMAIL_UPDATE_CONFIRMATION'; diff --git a/confiture-rest-api/prisma/migrations/20230929144159_add_password_reset_email_type/migration.sql b/confiture-rest-api/prisma/migrations/20230929144159_add_password_reset_email_type/migration.sql new file mode 100644 index 00000000..53274a65 --- /dev/null +++ b/confiture-rest-api/prisma/migrations/20230929144159_add_password_reset_email_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "EmailType" ADD VALUE 'PASSWORD_RESET_REQUEST'; diff --git a/confiture-rest-api/prisma/schema.prisma b/confiture-rest-api/prisma/schema.prisma index ea373844..61f6cb2e 100644 --- a/confiture-rest-api/prisma/schema.prisma +++ b/confiture-rest-api/prisma/schema.prisma @@ -50,7 +50,7 @@ model Audit { /// @DtoEntityHidden pages AuditedPage[] auditorName String? - auditorEmail String + auditorEmail String? showAuditorEmailInReport Boolean @default(false) auditorOrganisation String @@ -183,6 +183,12 @@ enum EmailStatus { enum EmailType { AUDIT_CREATION + ACCOUNT_VERIFICATION + ACCOUNT_CONFIRMATION + PASSWORD_UPDATE_CONFIRMATION + EMAIL_UPDATE_VERIFICATION + EMAIL_UPDATE_CONFIRMATION + PASSWORD_RESET_REQUEST } model EmailLog { @@ -195,3 +201,30 @@ model EmailLog { createdAt DateTime @default(now()) lastAttempt DateTime @default(now()) } + +model User { + id Int @id @default(autoincrement()) + /// @DtoEntityHidden + uid String @unique @default(uuid()) + + username String @unique + /// @DtoEntityHidden + password String + + /// @DtoEntityHidden + isVerified Boolean @default(false) + /// @DtoEntityHidden + verificationJti String? + + name String? + orgName String? + + /// @DtoEntityHidden + newEmail String? + /// @DtoEntityHidden + newEmailVerificationJti String? +} + +model ActiveFeedbackToken { + uid String @id +} \ No newline at end of file diff --git a/confiture-rest-api/src/app.module.ts b/confiture-rest-api/src/app.module.ts index 16a04c35..ab6d344a 100644 --- a/confiture-rest-api/src/app.module.ts +++ b/confiture-rest-api/src/app.module.ts @@ -5,6 +5,8 @@ import { AuditsModule } from './audits/audits.module'; import { HealthCheckController } from './health-check.controller'; import { configValidationSchema } from './config-validation-schema'; import { MailModule } from './mail/mail.module'; +import { AuthModule } from './auth/auth.module'; +import { ProfileModule } from './profile/profile.module'; @Module({ imports: [ @@ -15,6 +17,8 @@ import { MailModule } from './mail/mail.module'; FeedbackModule, AuditsModule, MailModule, + AuthModule, + ProfileModule, ], controllers: [HealthCheckController], }) diff --git a/confiture-rest-api/src/audits/audit.service.ts b/confiture-rest-api/src/audits/audit.service.ts index 4a2a3525..d268b47d 100644 --- a/confiture-rest-api/src/audits/audit.service.ts +++ b/confiture-rest-api/src/audits/audit.service.ts @@ -11,7 +11,7 @@ import { } from '@prisma/client'; import { nanoid } from 'nanoid'; import sharp from 'sharp'; -import { omit, setWith } from 'lodash'; +import { omit, pick, setWith } from 'lodash'; import { PrismaService } from '../prisma.service'; import * as RGAA from '../rgaa.json'; @@ -57,7 +57,6 @@ export class AuditService { auditType: data.auditType, auditorEmail: data.auditorEmail, - showAuditorEmailInReport: data.showAuditorEmailInReport, auditorName: data.auditorName, auditorOrganisation: data.auditorOrganisation, @@ -198,7 +197,6 @@ export class AuditService { initiator: data.initiator, auditorEmail: data.auditorEmail, - showAuditorEmailInReport: data.showAuditorEmailInReport, auditorName: data.auditorName, auditorOrganisation: data.auditorOrganisation, @@ -766,9 +764,7 @@ export class AuditService { // FIXME: some of the return data is never asked to the user context: { auditorName: audit.auditorName, - auditorEmail: audit.showAuditorEmailInReport - ? audit.auditorEmail - : null, + auditorEmail: null, desktopEnvironments: audit.environments .filter((e) => e.platform === 'desktop') .map((e) => ({ @@ -1112,4 +1108,100 @@ export class AuditService { return newAudit; } + + async anonymiseAudits(userEmail: string) { + await this.prisma.audit.updateMany({ + where: { + auditorEmail: userEmail, + }, + data: { + auditorEmail: null, + auditorName: null, + showAuditorEmailInReport: false, + }, + }); + } + + async getAuditsByAuditorEmail(email: string) { + const audits = await this.prisma.audit.findMany({ + where: { + auditorEmail: email, + }, + select: { + procedureName: true, + creationDate: true, + auditType: true, + editUniqueId: true, + consultUniqueId: true, + pages: { + select: { + results: true, + }, + }, + }, + }); + + return audits.map((a) => { + const results = a.pages.flatMap((p) => p.results); + + const progress = + results.filter((r) => r.status !== CriterionResultStatus.NOT_TESTED) + .length / + (CRITERIA_BY_AUDIT_TYPE[a.auditType].length * a.pages.length); + + let complianceLevel = null; + + if (progress >= 1) { + const resultsGroupedById = results.reduce< + Record + >((acc, c) => { + const key = `${c.topic}.${c.criterium}`; + if (acc[key]) { + acc[key].push(c); + } else { + acc[key] = [c]; + } + return acc; + }, {}); + + const results2 = CRITERIA_BY_AUDIT_TYPE[a.auditType].map( + (c) => resultsGroupedById[`${c.topic}.${c.criterium}`] ?? null, + ); + + const applicableCriteria = results2.filter( + (criteria) => + criteria && + criteria.some( + (c) => c.status !== CriterionResultStatus.NOT_APPLICABLE, + ), + ); + + const compliantCriteria = applicableCriteria.filter((criteria) => + criteria.every( + (c) => + c.status === CriterionResultStatus.COMPLIANT || + c.status === CriterionResultStatus.NOT_APPLICABLE, + ), + ); + + complianceLevel = Math.round( + (compliantCriteria.length / applicableCriteria.length) * 100, + ); + } + + return { + ...pick( + a, + 'procedureName', + 'editUniqueId', + 'consultUniqueId', + 'creationDate', + 'auditType', + ), + complianceLevel, + status: progress < 1 ? 'IN_PROGRESS' : 'COMPLETED', + estimatedCsvSize: 502 + a.pages.length * 318, + }; + }); + } } diff --git a/confiture-rest-api/src/audits/audits.controller.ts b/confiture-rest-api/src/audits/audits.controller.ts index 377f7fd5..900529d8 100644 --- a/confiture-rest-api/src/audits/audits.controller.ts +++ b/confiture-rest-api/src/audits/audits.controller.ts @@ -5,7 +5,6 @@ import { Delete, Get, GoneException, - Header, HttpStatus, NotFoundException, Param, @@ -37,6 +36,9 @@ import { UpdateAuditDto } from './update-audit.dto'; import { PatchAuditDto } from './patch-audit.dto'; import { UpdateResultsDto } from './update-results.dto'; import { UploadImageDto } from './upload-image.dto'; +import { AuthRequired } from 'src/auth/auth-required.decorator'; +import { User } from 'src/auth/user.decorator'; +import { AuthenticationJwtPayload } from 'src/auth/jwt-payloads'; @Controller('audits') @ApiTags('Audits') @@ -64,6 +66,15 @@ export class AuditsController { return audit; } + /** + * Retrieve list of audits to be displayed on user's dashboard. + */ + @Get() + @AuthRequired() + async getAuditList(@User() user: AuthenticationJwtPayload) { + return this.auditService.getAuditsByAuditorEmail(user.email); + } + /** Retrieve an audit from the database. */ @Get('/:uniqueId') @ApiOkResponse({ description: 'The audit was found.', type: Audit }) @@ -269,7 +280,7 @@ export class AuditsController { console.error(err); }); - return newAudit.editUniqueId; + return newAudit; } @Get('/:uniqueId/exports/csv') diff --git a/confiture-rest-api/src/audits/audits.module.ts b/confiture-rest-api/src/audits/audits.module.ts index 4f9e7ba7..c5738115 100644 --- a/confiture-rest-api/src/audits/audits.module.ts +++ b/confiture-rest-api/src/audits/audits.module.ts @@ -27,5 +27,6 @@ import { AuditExportService } from './audit-export.service'; }, }), ], + exports: [AuditService], }) export class AuditsModule {} diff --git a/confiture-rest-api/src/audits/create-audit.dto.ts b/confiture-rest-api/src/audits/create-audit.dto.ts index e54c2b5d..6156196d 100644 --- a/confiture-rest-api/src/audits/create-audit.dto.ts +++ b/confiture-rest-api/src/audits/create-audit.dto.ts @@ -2,7 +2,6 @@ import { AuditType } from '@prisma/client'; import { Type } from 'class-transformer'; import { IsArray, - IsBoolean, IsEmail, IsIn, IsNumber, @@ -64,12 +63,6 @@ export class CreateAuditDto { @IsOptional() auditorEmail?: string; - /** - * @example true - */ - @IsBoolean() - showAuditorEmailInReport: boolean; - /** * @example "WEB AUDIT SARL" */ diff --git a/confiture-rest-api/src/auth/auth-required.decorator.ts b/confiture-rest-api/src/auth/auth-required.decorator.ts new file mode 100644 index 00000000..2768c26b --- /dev/null +++ b/confiture-rest-api/src/auth/auth-required.decorator.ts @@ -0,0 +1,13 @@ +import { UseGuards, applyDecorators } from '@nestjs/common'; +import { AuthGuard } from './auth.guard'; +import { ApiBearerAuth, ApiUnauthorizedResponse } from '@nestjs/swagger'; + +export function AuthRequired() { + return applyDecorators( + ApiBearerAuth(), + ApiUnauthorizedResponse({ + description: 'You must authenticate yourself using a bearer token.', + }), + UseGuards(AuthGuard), + ); +} diff --git a/confiture-rest-api/src/auth/auth.controller.ts b/confiture-rest-api/src/auth/auth.controller.ts new file mode 100644 index 00000000..3b32b28b --- /dev/null +++ b/confiture-rest-api/src/auth/auth.controller.ts @@ -0,0 +1,340 @@ +import { + BadRequestException, + Body, + ConflictException, + Controller, + Delete, + Get, + HttpCode, + Post, + Put, + Query, + UnauthorizedException, +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiConflictResponse, + ApiCreatedResponse, + ApiOkResponse, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; + +import { AuditService } from '../audits/audit.service'; +import { FeedbackService } from '../feedback/feedback.service'; +import { MailService } from '../mail/mail.service'; +import { AuthRequired } from './auth-required.decorator'; +import { + AuthService, + InvalidVerificationTokenError, + SigninError, + TokenRegenerationError, + UsernameAlreadyExistsError, +} from './auth.service'; +import { CreateAccountDto } from './dto/create-account.dto'; +import { DeleteAccountResponseDto } from './dto/delete-account-response.dto'; +import { DeleteAccountDto } from './dto/delete-account.dto'; +import { ResendVerificationEmailDto } from './dto/resend-verification-email.dto'; +import { SigninDto } from './dto/signin.dto'; +import { UpdatePasswordDto } from './dto/update-password.dto'; +import { VerifyAccountDto } from './dto/verify-account.dto'; +import { AuthenticationJwtPayload } from './jwt-payloads'; +import { User } from './user.decorator'; +import { UpdateEmailDto } from './update-email.dto'; +import { VerifyEmailUpdateDto } from './verify-email-update.dto'; +import { CancelEmailUpdateDto } from './dto/cancel-email-update.dto'; + +@Controller('auth') +@ApiTags('Authentication') +export class AuthController { + constructor( + private readonly auth: AuthService, + private readonly email: MailService, + private readonly feedback: FeedbackService, + private readonly audit: AuditService, + ) {} + + /** + * Create a new user account. + * + * The account is not useable right away, it first needs to be verified. + * To do this, an email containing a verification link is sent to the user + * mail adress (their username). + * + * This link contains a *verification token* that will be sent to the + * `/api/auth/verify` endpoint to activate the account. + */ + @Post('signup') + @ApiConflictResponse({ + description: 'A verified account with this username already exists.', + }) + @ApiCreatedResponse({ + description: 'Account successfully created (pending verification).', + }) + async createAccount(@Body() body: CreateAccountDto) { + try { + const verificationToken = await this.auth.createUnverifiedUser( + body.username, + body.password, + ); + await this.email.sendAccountVerificationEmail( + body.username, + verificationToken, + ); + } catch (e) { + if (e instanceof UsernameAlreadyExistsError) { + throw new ConflictException(); + } + throw e; + } + } + + @Post('resend-verification-email') + @HttpCode(200) + @ApiOkResponse({ description: 'Verification email has been resent.' }) + @ApiBadRequestResponse({ + description: + 'Either no such user exist or the account has already been verified.', + }) + async resetVarificationEmail(@Body() body: ResendVerificationEmailDto) { + try { + const verificationToken = await this.auth.regenerateVerificationToken( + body.username, + ); + console.log('verificationToken:', verificationToken); + await this.email.sendAccountVerificationEmail( + body.username, + verificationToken, + ); + } catch (e) { + if (e instanceof TokenRegenerationError) { + console.log('Token regeneration failed:', e.message); + throw new BadRequestException(); + } + throw e; + } + } + + /** + * Verify an account by validating the given token. + */ + @Post('verify') + @HttpCode(200) + @ApiOkResponse({ description: 'Account successfully verified.' }) + @ApiUnauthorizedResponse({ description: 'Invalid verification token.' }) + async verifyAccount(@Body() body: VerifyAccountDto) { + try { + const email = await this.auth.getEmailFromVerificationToken(body.token); + + await this.auth.verifyAccount(body.token); + this.email.sendAccountConfirmationEmail(email).catch((err) => { + console.error(`Failed to send email for account creation ${email}`); + console.error(err); + }); + } catch (e) { + if (e instanceof InvalidVerificationTokenError) { + console.log('Account verification failed:', e.message); + throw new UnauthorizedException('Invalid token'); + } + throw e; + } + } + + /** Check if account is verified. */ + @Get('verified') + async isAccountVerified(@Query('username') username: string) { + return await this.auth.isAccountVerified(username); + } + + /** + * Login using *username and password*. An authentication token is returned. + * + * The authentication token should be included in HTTP requests using the `Authorization` header. + * + * See: https://swagger.io/docs/specification/authentication/bearer-authentication/ + */ + @Post('signin') + @ApiCreatedResponse({ + description: 'Successfully authenticated.', + type: String, + }) + @ApiUnauthorizedResponse({ + description: + 'Invalid credentials. The `message` property of the response body details the reason.', + }) + async signin(@Body() body: SigninDto) { + try { + return await this.auth.signin(body.username, body.password); + } catch (e) { + if (e instanceof SigninError) { + throw new UnauthorizedException(e.message); + } + throw e; + } + } + + @Post('refresh') + @AuthRequired() + async refreshToken(@User() user: AuthenticationJwtPayload) { + try { + return await this.auth.refreshToken(user.sub); + } catch (e) { + if (e instanceof SigninError) { + throw new UnauthorizedException(e.message); + } + throw e; + } + } + + @Delete('account') + @ApiOkResponse({ + description: 'The account was succesfully deleted.', + type: DeleteAccountResponseDto, + }) + @AuthRequired() + async deleteAccount( + @Body() body: DeleteAccountDto, + @User() user: AuthenticationJwtPayload, + ) { + if (!(await this.auth.checkCredentials(user.email, body.password))) { + throw new UnauthorizedException(); + } + + await this.audit.anonymiseAudits(user.email); + + const feedbackToken = await this.feedback.generateFeedbackToken(); + + await this.auth.deleteAccount(user.email); + + return { + feedbackToken, + }; + } + + @Put('update-password') + @ApiOkResponse({ + description: 'The password was succesfully updated.', + }) + @ApiBadRequestResponse({ + description: 'The new password is identical to the old password.', + }) + @ApiUnauthorizedResponse({ + description: 'Wrong old password or invalid bearer token.', + }) + @AuthRequired() + async updatePassword( + @Body() body: UpdatePasswordDto, + @User() user: AuthenticationJwtPayload, + ) { + const passwordCheck = await this.auth.checkCredentials( + user.email, + body.oldPassword, + ); + + if (!passwordCheck) { + throw new UnauthorizedException(); + } + + if (body.newPassword === body.oldPassword) { + throw new BadRequestException(); + } + + await this.auth.updatePassword(user.email, body.newPassword); + + this.email.sendPasswordUpdateConfirmation(user.email).catch((err) => { + console.error(`Failed to send email for password update ${user.email}`); + console.error(err); + }); + + return; + } + /** Update the user's email adress. The change is not effective immediately. + * The new email adress must first be verified using the + * `account/verify-email-update` route. */ + @Put('account/email') + @ApiOkResponse({ + description: + 'Email update successfully requested. The new email must be confirmed by using the `account/verify-email-update` route.', + }) + @AuthRequired() + async updateEmail( + @Body() body: UpdateEmailDto, + @User() user: AuthenticationJwtPayload, + ) { + if (!(await this.auth.checkCredentialsWithUid(user.sub, body.password))) { + throw new UnauthorizedException(); + } + try { + const verificationToken = await this.auth.addNewEmail( + user.sub, + body.newEmail, + ); + + await this.email.sendNewEmailVerificationEmail( + body.newEmail, + verificationToken, + ); + } catch (e) { + if (e instanceof UsernameAlreadyExistsError) { + throw new ConflictException(); + } + throw e; + } + } + + /** Trigger a new verification email for the email update. */ + @Post('account/resend-email-update-verification-email') + @HttpCode(200) + @AuthRequired() + async resendNewEmailVerificationEmail( + @User() user: AuthenticationJwtPayload, + ) { + try { + const { token, email } = + await this.auth.regenerateEmailUpdateVerificationToken(user.sub); + await this.email.sendNewEmailVerificationEmail(email, token); + } catch (e) { + if (e instanceof TokenRegenerationError) { + console.log('Token regeneration failed:', e.message); + throw new BadRequestException(); + } + throw e; + } + } + + /** Verify an email adress by receiving the verification token sent the wanted email adress. */ + @Post('account/verify-email-update') + async verifyEmailUpdate(@Body() body: VerifyEmailUpdateDto) { + try { + const email = await this.auth.getEmailFromVerificationToken(body.token); + + await this.auth.verifyEmailUpdate(body.token); + this.email.sendEmailUpdateConfirmationEmail(email); + } catch (e) { + if (e instanceof InvalidVerificationTokenError) { + console.log('Email update verification failed:', e.message); + throw new UnauthorizedException('Invalid token'); + } + throw e; + } + } + + @Post('account/cancel-email-update') + @AuthRequired() + async cancelEmailUpdate(@User() user: AuthenticationJwtPayload) { + await this.auth.cancelEmailUpdate(user.email); + } + + /** Checks if given email is verified for the authenticated user. */ + @Get('account/verified-email-update') + @ApiOkResponse({ + type: Boolean, + }) + @AuthRequired() + async isNewEmailVerified( + @User() user: AuthenticationJwtPayload, + @Query('email') email: string, + ) { + return await this.auth.userHasEmail(user.sub, email); + } +} diff --git a/confiture-rest-api/src/auth/auth.guard.ts b/confiture-rest-api/src/auth/auth.guard.ts new file mode 100644 index 00000000..db39965c --- /dev/null +++ b/confiture-rest-api/src/auth/auth.guard.ts @@ -0,0 +1,40 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import type { Request } from 'express'; +import { AuthenticationJwtPayload } from './jwt-payloads'; + +@Injectable() +export class AuthGuard implements CanActivate { + constructor(private readonly jwt: JwtService) {} + + async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + if (!token) { + throw new UnauthorizedException(); + } + + try { + const payload = (await this.jwt.verifyAsync( + token, + )) as AuthenticationJwtPayload; + // 💡 We're assigning the payload to the request object here + // so that we can access it in our route handlers + request['user'] = payload; + } catch { + throw new UnauthorizedException(); + } + + return true; + } + + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/confiture-rest-api/src/auth/auth.module.ts b/confiture-rest-api/src/auth/auth.module.ts new file mode 100644 index 00000000..5e68ad64 --- /dev/null +++ b/confiture-rest-api/src/auth/auth.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { FeedbackModule } from 'src/feedback/feedback.module'; +import { MailModule } from 'src/mail/mail.module'; +import { PrismaService } from 'src/prisma.service'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { AuditsModule } from 'src/audits/audits.module'; +import { PasswordResetController } from './password-reset.controller'; + +@Module({ + providers: [AuthService, PrismaService], + controllers: [AuthController, PasswordResetController], + imports: [ + MailModule, + JwtModule.registerAsync({ + inject: [ConfigService], + global: true, + useFactory: async (config: ConfigService) => ({ + secret: config.get('JWT_SECRET'), + }), + }), + FeedbackModule, + AuditsModule, + ], +}) +export class AuthModule {} diff --git a/confiture-rest-api/src/auth/auth.service.ts b/confiture-rest-api/src/auth/auth.service.ts new file mode 100644 index 00000000..6380cb30 --- /dev/null +++ b/confiture-rest-api/src/auth/auth.service.ts @@ -0,0 +1,433 @@ +import { Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { compare, hash } from 'bcrypt'; +import { nanoid } from 'nanoid'; + +import { PrismaService } from '../prisma.service'; +import { + AccountVerificationJwtPayload, + AuthenticationJwtPayload, + NewEmailVerificationJwtPayload, + RequestPasswordResetJwtPayload, +} from './jwt-payloads'; + +export class UsernameAlreadyExistsError extends Error { + readonly username: string; + + constructor(username: string) { + super(`Username ${username} alredy exists.`); + this.name = 'UsernameAlreadyExistsError'; + this.username = username; + } +} + +export class InvalidVerificationTokenError extends Error { + constructor(reason) { + super(reason); + this.name = 'InvalidVerificationTokenError'; + } +} + +export class SigninError extends Error { + constructor(reason) { + super(reason); + this.name = 'SigninError'; + } +} + +export class TokenRegenerationError extends Error { + constructor(reason) { + super(reason); + this.name = 'TokenRegenerationError'; + } +} + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly jwt: JwtService, + ) {} + + /** + * Create a new unverified user and return a verification token. + */ + async createUnverifiedUser( + username: string, + password: string, + ): Promise { + const passwordHash = await this.hashPassword(password); + + // Check if an already verified user exists with this username + await this.prisma.user.findUnique({ where: { username } }).then((user) => { + if (user?.isVerified) { + throw new UsernameAlreadyExistsError(username); + } + }); + + // We use upsert because the user might already exist but is not verified yet. + const unverifiedUser = await this.prisma.user.upsert({ + where: { username }, + create: { + username, + password: passwordHash, + isVerified: false, + verificationJti: nanoid(), + }, + update: { + password: passwordHash, + verificationJti: nanoid(), + }, + }); + + const verificationToken = await this.generateVerificationToken( + unverifiedUser.uid, + username, + unverifiedUser.verificationJti, + ); + + return verificationToken; + } + + /** + * Generate a new verification token and invalidates the previous one. + */ + async regenerateVerificationToken(username: string): Promise { + const user = await this.prisma.user.findUnique({ where: { username } }); + if (!user) { + throw new TokenRegenerationError('User not found'); + } + + if (user.isVerified) { + throw new TokenRegenerationError('User is already verified'); + } + + const newJti = nanoid(); + await this.prisma.user.update({ + where: { username }, + data: { verificationJti: newJti }, + }); + + const verificationToken = await this.generateVerificationToken( + user.uid, + user.username, + newJti, + ); + return verificationToken; + } + + async verifyAccount(token: string) { + const payload = (await this.jwt.verifyAsync(token).catch(() => { + throw new InvalidVerificationTokenError('Invalid JWT'); + })) as AccountVerificationJwtPayload; + const { sub: uid, jti } = payload; + + // Addition checks : user exists, user needs verification, token is the last one + { + const user = await this.prisma.user.findUnique({ where: { uid } }); + + if (!user) { + throw new InvalidVerificationTokenError('User not found'); + } + + if (user.isVerified) { + throw new InvalidVerificationTokenError('User is already verified'); + } + + if (user.verificationJti !== jti) { + throw new InvalidVerificationTokenError( + 'Token is not the latest generated token', + ); + } + } + + await this.prisma.user.update({ + where: { uid }, + data: { + isVerified: true, + verificationJti: null, + }, + }); + } + + /** + * Verify user credentials and return an authentication token. + */ + async signin(username: string, password: string): Promise { + const user = await this.prisma.user.findUnique({ where: { username } }); + + if (!user) { + throw new SigninError('unknown_user'); + } + + if (!user.isVerified) { + throw new SigninError('unknown_user'); + } + + const match = await compare(password, user.password); + + if (!match) { + throw new SigninError('wrong_password'); + } + + const payload: AuthenticationJwtPayload = { + sub: user.uid, + email: user.username, + name: user.name, + org: user.orgName, + }; + const token = await this.jwt.signAsync(payload, { expiresIn: '24h' }); + + return token; + } + + /** Generate a new auth token. */ + async refreshToken(uid: string): Promise { + const user = await this.prisma.user.findUnique({ where: { uid } }); + if (!user) { + throw new SigninError('unknown_user'); + } + if (!user.isVerified) { + throw new SigninError('unknown_user'); + } + const payload: AuthenticationJwtPayload = { + sub: user.uid, + email: user.username, + name: user.name, + org: user.orgName, + }; + const token = await this.jwt.signAsync(payload, { expiresIn: '24h' }); + return token; + } + + async isAccountVerified(username: string): Promise { + const user = await this.prisma.user.findUnique({ where: { username } }); + return !!user && user.isVerified; + } + + async getEmailFromVerificationToken(token: string) { + const payload = await this.jwt.verifyAsync(token).catch(() => { + throw new InvalidVerificationTokenError('Invalid JWT'); + }); + const { email } = payload; + + return email; + } + + private generateVerificationToken( + uid: string, + email: string, + jti: string, + ): Promise { + const payload: AccountVerificationJwtPayload = { + verification: 'new-account', + sub: uid, + email, + jti, + }; + const verificationToken = this.jwt.signAsync(payload, { expiresIn: '1h' }); + return verificationToken; + } + + async checkCredentials(username: string, password: string): Promise { + const user = await this.prisma.user.findUnique({ where: { username } }); + return user && user.isVerified && (await compare(password, user.password)); + } + + async checkCredentialsWithUid( + uid: string, + password: string, + ): Promise { + const user = await this.prisma.user.findUnique({ where: { uid } }); + return user && user.isVerified && (await compare(password, user.password)); + } + + async deleteAccount(username: string) { + await this.prisma.user.delete({ where: { username } }); + } + + async updatePassword(username: string, newPassword: string) { + const hash = await this.hashPassword(newPassword); + await this.prisma.user.update({ + where: { username }, + data: { password: hash }, + }); + } + + private hashPassword(password: string) { + return hash(password, 10); + } + + async addNewEmail(uid: string, newEmail: string) { + // Check if an user already exists with this username + await this.prisma.user + .findUnique({ where: { username: newEmail } }) + .then((user) => { + if (user) { + throw new UsernameAlreadyExistsError(newEmail); + } + }); + + const user = await this.prisma.user.update({ + where: { uid }, + data: { + newEmail, + newEmailVerificationJti: nanoid(), + }, + }); + + const verificationToken = await this.generateNewEmailVerificationToken( + user.uid, + newEmail, + user.newEmailVerificationJti, + ); + + return verificationToken; + } + + private generateNewEmailVerificationToken( + uid: string, + email: string, + jti: string, + ): Promise { + const payload: NewEmailVerificationJwtPayload = { + verification: 'update-email', + sub: uid, + email, + jti, + }; + const verificationToken = this.jwt.signAsync(payload, { expiresIn: '1h' }); + return verificationToken; + } + + /** Generate a new *email update* verification token and update the stored jti for given user. */ + async regenerateEmailUpdateVerificationToken( + uid: string, + ): Promise<{ email: string; token: string }> { + const user = await this.prisma.user.findUnique({ where: { uid } }); + if (!user) { + throw new TokenRegenerationError('User not found'); + } + + if (!user.newEmail) { + throw new TokenRegenerationError( + 'User is not in the process of updating email', + ); + } + + const newJti = nanoid(); + await this.prisma.user.update({ + where: { uid }, + data: { newEmailVerificationJti: newJti }, + }); + const token = await this.generateNewEmailVerificationToken( + user.uid, + user.newEmail, + newJti, + ); + + return { token, email: user.newEmail }; + } + + /** Cancel email update by invalidating the verification token. */ + async cancelEmailUpdate(email: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { username: email }, + }); + if (!user) { + throw new TokenRegenerationError('User not found'); + } + + await this.prisma.user.update({ + where: { username: email }, + data: { newEmailVerificationJti: null }, + }); + } + + async verifyEmailUpdate(token: string) { + const payload = (await this.jwt.verifyAsync(token).catch(() => { + throw new InvalidVerificationTokenError('Invalid JWT'); + })) as NewEmailVerificationJwtPayload; + const { sub: uid, jti, email } = payload; + + // Addition checks : user exists, user needs email update verification, token is the last one + { + const user = await this.prisma.user.findUnique({ where: { uid } }); + + if (!user) { + throw new InvalidVerificationTokenError('User not found'); + } + + if (!user.newEmail) { + throw new InvalidVerificationTokenError('No pending email update'); + } + + if (user.newEmailVerificationJti !== jti) { + throw new InvalidVerificationTokenError( + 'Token is not the latest generated token', + ); + } + } + + await this.prisma.user.update({ + where: { uid }, + data: { + username: email, + newEmail: null, + newEmailVerificationJti: null, + }, + }); + } + + async userHasEmail(uid: string, email: string) { + try { + await this.prisma.user.findFirstOrThrow({ + where: { uid, username: email, newEmail: null }, + }); + return true; + } catch { + return false; + } + } + + async generatePasswordResetVerificationToken(email: string) { + // verify user exists + await this.prisma.user + .findUniqueOrThrow({ where: { username: email } }) + .catch(() => { + throw new TokenRegenerationError('User not found'); + }); + + const payload: RequestPasswordResetJwtPayload = { + email, + }; + const verificationToken = this.jwt.signAsync(payload, { expiresIn: '1h' }); + return verificationToken; + } + + async resetPassword(newPassword: string, token: string) { + const { email } = (await this.jwt.verifyAsync(token).catch(() => { + throw new InvalidVerificationTokenError('Invalid JWT'); + })) as NewEmailVerificationJwtPayload; + + const hash = await this.hashPassword(newPassword); + + await this.prisma.user + .update({ + where: { username: email }, + data: { + password: hash, + }, + }) + .catch((err) => { + // User not found + // https://www.prisma.io/docs/reference/api-reference/error-reference#p2025 + if (err?.code === 'P2025') { + throw new InvalidVerificationTokenError('User not found'); + } + throw err; + }); + + return email; + } +} diff --git a/confiture-rest-api/src/auth/dto/cancel-email-update.dto.ts b/confiture-rest-api/src/auth/dto/cancel-email-update.dto.ts new file mode 100644 index 00000000..de52e9c3 --- /dev/null +++ b/confiture-rest-api/src/auth/dto/cancel-email-update.dto.ts @@ -0,0 +1,7 @@ +import { IsEmail, IsString } from 'class-validator'; + +export class CancelEmailUpdateDto { + @IsString() + @IsEmail() + email: string; +} diff --git a/confiture-rest-api/src/auth/dto/create-account.dto.ts b/confiture-rest-api/src/auth/dto/create-account.dto.ts new file mode 100644 index 00000000..eca48958 --- /dev/null +++ b/confiture-rest-api/src/auth/dto/create-account.dto.ts @@ -0,0 +1,11 @@ +import { IsEmail, IsString, MinLength } from 'class-validator'; + +export class CreateAccountDto { + @IsString() + @IsEmail() + username: string; + + @IsString() + @MinLength(12) + password: string; +} diff --git a/confiture-rest-api/src/auth/dto/delete-account-response.dto.ts b/confiture-rest-api/src/auth/dto/delete-account-response.dto.ts new file mode 100644 index 00000000..e28f534f --- /dev/null +++ b/confiture-rest-api/src/auth/dto/delete-account-response.dto.ts @@ -0,0 +1,4 @@ +export class DeleteAccountResponseDto { + /** JWT token to send with feedback, if any. */ + feedbackToken: string; +} diff --git a/confiture-rest-api/src/auth/dto/delete-account.dto.ts b/confiture-rest-api/src/auth/dto/delete-account.dto.ts new file mode 100644 index 00000000..580b0624 --- /dev/null +++ b/confiture-rest-api/src/auth/dto/delete-account.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class DeleteAccountDto { + @IsString() + password: string; +} diff --git a/confiture-rest-api/src/auth/dto/request-password-reset.dto.ts b/confiture-rest-api/src/auth/dto/request-password-reset.dto.ts new file mode 100644 index 00000000..ba377483 --- /dev/null +++ b/confiture-rest-api/src/auth/dto/request-password-reset.dto.ts @@ -0,0 +1,6 @@ +import { IsEmail } from 'class-validator'; + +export class RequestPasswordResetDto { + @IsEmail() + email: string; +} diff --git a/confiture-rest-api/src/auth/dto/resend-verification-email.dto.ts b/confiture-rest-api/src/auth/dto/resend-verification-email.dto.ts new file mode 100644 index 00000000..9fe3e357 --- /dev/null +++ b/confiture-rest-api/src/auth/dto/resend-verification-email.dto.ts @@ -0,0 +1,7 @@ +import { IsEmail, IsString } from 'class-validator'; + +export class ResendVerificationEmailDto { + @IsString() + @IsEmail() + username: string; +} diff --git a/confiture-rest-api/src/auth/dto/reset-password.dto.ts b/confiture-rest-api/src/auth/dto/reset-password.dto.ts new file mode 100644 index 00000000..8a6b7ea5 --- /dev/null +++ b/confiture-rest-api/src/auth/dto/reset-password.dto.ts @@ -0,0 +1,8 @@ +import { IsString } from 'class-validator'; + +export class ResetPasswordDto { + @IsString() + newPassword: string; + @IsString() + token: string; +} diff --git a/confiture-rest-api/src/auth/dto/signin.dto.ts b/confiture-rest-api/src/auth/dto/signin.dto.ts new file mode 100644 index 00000000..4f2f99af --- /dev/null +++ b/confiture-rest-api/src/auth/dto/signin.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; + +export class SigninDto { + @IsString() + username: string; + + @IsString() + password: string; +} diff --git a/confiture-rest-api/src/auth/dto/update-password.dto.ts b/confiture-rest-api/src/auth/dto/update-password.dto.ts new file mode 100644 index 00000000..29180901 --- /dev/null +++ b/confiture-rest-api/src/auth/dto/update-password.dto.ts @@ -0,0 +1,8 @@ +import { IsString } from 'class-validator'; + +export class UpdatePasswordDto { + @IsString() + oldPassword: string; + @IsString() + newPassword: string; +} diff --git a/confiture-rest-api/src/auth/dto/verify-account.dto.ts b/confiture-rest-api/src/auth/dto/verify-account.dto.ts new file mode 100644 index 00000000..0a069175 --- /dev/null +++ b/confiture-rest-api/src/auth/dto/verify-account.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class VerifyAccountDto { + @IsString() + token: string; +} diff --git a/confiture-rest-api/src/auth/jwt-payloads.ts b/confiture-rest-api/src/auth/jwt-payloads.ts new file mode 100644 index 00000000..f55afe62 --- /dev/null +++ b/confiture-rest-api/src/auth/jwt-payloads.ts @@ -0,0 +1,56 @@ +export interface AuthenticationJwtPayload { + /** User uid */ + sub: string; + + /** User email */ + email: string; + + /** User full name */ + name: string | null; + + /** User organization. */ + org: string | null; +} + +export interface AccountVerificationJwtPayload { + verification: 'new-account'; + + /** User uid */ + sub: string; + + /** User email */ + email: string; + + /** + * JWT ID + * + * At any time, only one verification token should be considered valid. The + * `jti` property will be compared with the one stored in the DB to make sure + * this is the latest verification token generated. + */ + jti: string; +} + +export interface NewEmailVerificationJwtPayload { + verification: 'update-email'; + + /** User uid */ + sub: string; + + /** User email. */ + email: string; + + /** + * JWT ID + * + * At any time, only one verification token should be considered valid. The + * `jti` property will be compared with the one stored in the DB (User.newEmailVerificationJti) to make sure + * this is the latest verification token generated. + */ + jti: string; +} + +export interface RequestPasswordResetJwtPayload { + /** Email address of the requested password to be reset. */ + email: string; +} diff --git a/confiture-rest-api/src/auth/password-reset.controller.ts b/confiture-rest-api/src/auth/password-reset.controller.ts new file mode 100644 index 00000000..3a44b076 --- /dev/null +++ b/confiture-rest-api/src/auth/password-reset.controller.ts @@ -0,0 +1,46 @@ +import { MailService } from 'src/mail/mail.service'; +import { AuthService, TokenRegenerationError } from './auth.service'; +import { Body, Controller, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { RequestPasswordResetDto } from './dto/request-password-reset.dto'; +import { ResetPasswordDto } from './dto/reset-password.dto'; + +const SummerBody = Body; + +@Controller('auth') +@ApiTags('Authentication') +export class PasswordResetController { + constructor( + private readonly auth: AuthService, + private readonly email: MailService, + ) {} + + @Post('account/request-password-reset') + async requestPasswordReset( + @SummerBody() summerBody: RequestPasswordResetDto, + ) { + try { + const verificationToken = + await this.auth.generatePasswordResetVerificationToken( + summerBody.email, + ); + + await this.email.sendPasswordResetEmail( + summerBody.email, + verificationToken, + ); + } catch (e) { + if (e instanceof TokenRegenerationError) { + // In case of error (user not found), we just dont send the email. + } else { + throw e; + } + } + } + + @Post('account/reset-password') + async resetPassword(@Body() body: ResetPasswordDto) { + const email = await this.auth.resetPassword(body.newPassword, body.token); + this.email.sendPasswordUpdateConfirmation(email); + } +} diff --git a/confiture-rest-api/src/auth/update-email.dto.ts b/confiture-rest-api/src/auth/update-email.dto.ts new file mode 100644 index 00000000..62bc9740 --- /dev/null +++ b/confiture-rest-api/src/auth/update-email.dto.ts @@ -0,0 +1,10 @@ +import { IsEmail, IsString } from 'class-validator'; + +export class UpdateEmailDto { + @IsString() + @IsEmail() + newEmail: string; + + @IsString() + password: string; +} diff --git a/confiture-rest-api/src/auth/user.decorator.ts b/confiture-rest-api/src/auth/user.decorator.ts new file mode 100644 index 00000000..69bdbaac --- /dev/null +++ b/confiture-rest-api/src/auth/user.decorator.ts @@ -0,0 +1,8 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const User = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.user; + }, +); diff --git a/confiture-rest-api/src/auth/verify-email-update.dto.ts b/confiture-rest-api/src/auth/verify-email-update.dto.ts new file mode 100644 index 00000000..7ea79315 --- /dev/null +++ b/confiture-rest-api/src/auth/verify-email-update.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class VerifyEmailUpdateDto { + @IsString() + token: string; +} diff --git a/confiture-rest-api/src/config-validation-schema.ts b/confiture-rest-api/src/config-validation-schema.ts index bd4ee361..5f3509e7 100644 --- a/confiture-rest-api/src/config-validation-schema.ts +++ b/confiture-rest-api/src/config-validation-schema.ts @@ -28,4 +28,5 @@ export const configValidationSchema = Joi.object({ .required(), AWS_ACCESS_KEY_ID: Joi.string().required(), AWS_SECRET_ACCESS_KEY: Joi.string().required(), + JWT_SECRET: Joi.string().required(), }); diff --git a/confiture-rest-api/src/feedback/account-deletion-feedback.dto.ts b/confiture-rest-api/src/feedback/account-deletion-feedback.dto.ts new file mode 100644 index 00000000..084fba93 --- /dev/null +++ b/confiture-rest-api/src/feedback/account-deletion-feedback.dto.ts @@ -0,0 +1,10 @@ +import { IsString } from 'class-validator'; + +export class AccountDeletionFeedbackDto { + @IsString() + feedback: string; + + /** Token returned by the */ + @IsString() + feedbackToken: string; +} diff --git a/confiture-rest-api/src/feedback/feedback.controller.ts b/confiture-rest-api/src/feedback/feedback.controller.ts index b0af5b90..0b80b3cb 100644 --- a/confiture-rest-api/src/feedback/feedback.controller.ts +++ b/confiture-rest-api/src/feedback/feedback.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Post } from '@nestjs/common'; import { ApiCreatedResponse } from '@nestjs/swagger'; import { FeedbackService } from './feedback.service'; import { NewFeedbackDto } from './new-feedback.dto'; +import { AccountDeletionFeedbackDto } from './account-deletion-feedback.dto'; @Controller('feedback') export class FeedbackController { @@ -17,4 +18,12 @@ export class FeedbackController { async sendFeedback(@Body() body: NewFeedbackDto) { await this.feedbackService.saveFeedback(body); } + + @Post('account-deleted') + async sendAccountDeletionFeedback(@Body() body: AccountDeletionFeedbackDto) { + await this.feedbackService.saveAccountDeletionFeedback( + body.feedback, + body.feedbackToken, + ); + } } diff --git a/confiture-rest-api/src/feedback/feedback.module.ts b/confiture-rest-api/src/feedback/feedback.module.ts index 2e69abb9..bce82370 100644 --- a/confiture-rest-api/src/feedback/feedback.module.ts +++ b/confiture-rest-api/src/feedback/feedback.module.ts @@ -1,9 +1,25 @@ import { Module } from '@nestjs/common'; import { FeedbackService } from './feedback.service'; import { FeedbackController } from './feedback.controller'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from 'src/prisma.service'; @Module({ - providers: [FeedbackService], + // FIXME: put PrismaService into a global module so the service is not instanciated multiple times + providers: [FeedbackService, PrismaService], controllers: [FeedbackController], + imports: [ + // FIXME: Even tho this module is already imported in AuthModule using the + // `global` option. When used in the feedback module, it is as it was not configured... + // So we import it twice + JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: async (config: ConfigService) => ({ + secret: config.get('JWT_SECRET'), + }), + }), + ], + exports: [FeedbackService], }) export class FeedbackModule {} diff --git a/confiture-rest-api/src/feedback/feedback.service.ts b/confiture-rest-api/src/feedback/feedback.service.ts index c21cbb01..62a6ae2a 100644 --- a/confiture-rest-api/src/feedback/feedback.service.ts +++ b/confiture-rest-api/src/feedback/feedback.service.ts @@ -1,11 +1,43 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import got, { HTTPError } from 'got'; import { NewFeedbackDto } from './new-feedback.dto'; +import { JwtService } from '@nestjs/jwt'; +import { PrismaService } from 'src/prisma.service'; +import { nanoid } from 'nanoid'; @Injectable() export class FeedbackService { - constructor(private readonly config: ConfigService) {} + constructor( + private readonly config: ConfigService, + private readonly jwt: JwtService, + private readonly prisma: PrismaService, + ) {} + + private async sendDataToAirtable(data: any) { + const response = await got + .post( + `https://api.airtable.com/v0/${this.config.get( + 'AIRTABLE_BASE_ID', + )}/${this.config.get('AIRTABLE_TABLE_ID')}`, + { + json: data, + headers: { + Authorization: `Bearer ${this.config.get('AIRTABLE_ACCESS_TOKEN')}`, + }, + }, + ) + .json<{ records: { id: string }[] }>() + .catch((err) => { + if (err instanceof HTTPError) { + console.log('Failed to submit to airtable'); + console.log(err.response.body); + } + throw err; + }); + + console.log('Added feedback to Airtable : %s', response.records[0].id); + } async saveFeedback(feedback: NewFeedbackDto) { const data = { @@ -24,28 +56,61 @@ export class FeedbackService { }, ], }; + await this.sendDataToAirtable(data); + } - const response = await got - .post( - `https://api.airtable.com/v0/${this.config.get( - 'AIRTABLE_BASE_ID', - )}/${this.config.get('AIRTABLE_TABLE_ID')}`, + /** Generate a single use token to be given to the API along account deletion feedback. */ + async generateFeedbackToken() { + const payload = { + feedback: 'accountDeletion', + jti: nanoid(), + }; + const token = await this.jwt.signAsync(payload); + await this.prisma.activeFeedbackToken.create({ + data: { uid: payload.jti }, + }); + return token; + } + + async saveAccountDeletionFeedback(feedback: string, feedbackToken: string) { + const jti = await this.verifyFeedbackToken(feedbackToken); + const data = { + records: [ { - json: data, - headers: { - Authorization: `Bearer ${this.config.get('AIRTABLE_ACCESS_TOKEN')}`, + fields: { + 'Remarques générales': feedback, + Source: 'Avis suppression de compte', }, }, - ) - .json<{ records: { id: string }[] }>() - .catch((err) => { - if (err instanceof HTTPError) { - console.log('Failed to submit to airtable'); - console.log(err.response.body); - } - throw err; + ], + }; + await this.sendDataToAirtable(data); + await this.consumeFeedbackToken(jti); + } + + /** Check the given token is valid. If not throw an UnauthorizedException (401) */ + private async verifyFeedbackToken(token: string) { + try { + const payload = await this.jwt.verifyAsync(token); + + if (payload?.feedback !== 'accountDeletion' || !payload?.jti) { + throw 'Wrong token format'; + } + + await this.prisma.activeFeedbackToken.findUniqueOrThrow({ + where: { uid: payload.jti }, }); - console.log('Added feedback to Airtable : %s', response.records[0].id); + return payload.jti; + } catch (e) { + throw new UnauthorizedException('Invalid feedback token'); + } + } + + /** Consume the feedback token so that the user cannot send multiple account deletion feedbacks without... deleting their account. */ + private async consumeFeedbackToken(jti: string) { + await this.prisma.activeFeedbackToken.delete({ + where: { uid: jti }, + }); } } diff --git a/confiture-rest-api/src/mail/account-confirmation-email.ts b/confiture-rest-api/src/mail/account-confirmation-email.ts new file mode 100644 index 00000000..b16bbd33 --- /dev/null +++ b/confiture-rest-api/src/mail/account-confirmation-email.ts @@ -0,0 +1,19 @@ +import { renderMailTemplate } from './render-mjml-template'; + +export function subject(): string { + return `Ara : compte créé avec succès`; +} + +export function html(): string { + return renderMailTemplate('account-confirmation', null); +} + +export function plain(): string { + return ` + Bonjour, + + Votre compte Ara a été créé avec succès. + + Vous avez une question ? Vous pouvez nous contacter en utilisant l’adresse e-mail ara@design.numerique.gouv.fr. + `; +} diff --git a/confiture-rest-api/src/mail/account-verification-email.ts b/confiture-rest-api/src/mail/account-verification-email.ts new file mode 100644 index 00000000..5af11850 --- /dev/null +++ b/confiture-rest-api/src/mail/account-verification-email.ts @@ -0,0 +1,25 @@ +import { renderMailTemplate } from './render-mjml-template'; + +export interface AccountVerificationEmailData { + verificationLink: string; +} + +export function subject(): string { + return `Ara : vérification du compte`; +} + +export function html(data: AccountVerificationEmailData): string { + return renderMailTemplate('account-verification', data); +} + +export function plain(data: AccountVerificationEmailData): string { + return ` + Bonjour, + + Pour finalisez la création de votre compte, veuillez cliquer sur le lien suivant : : ${data.verificationLink}. Ce lien est valable 1h. + + Si vous ne voulez pas créer de compte sur Ara ou si vous n’avez pas demandé à créer de compte, vous pouvez ignorer et supprimer cet e-mail. + + Vous avez une question ? Vous pouvez nous contacter en utilisant l’adresse e-mail ara@design.numerique.gouv.fr. + `; +} diff --git a/confiture-rest-api/src/mail/mail.service.ts b/confiture-rest-api/src/mail/mail.service.ts index ce25e62f..1ac0c4fd 100644 --- a/confiture-rest-api/src/mail/mail.service.ts +++ b/confiture-rest-api/src/mail/mail.service.ts @@ -1,15 +1,27 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Audit, EmailStatus, EmailType } from '@prisma/client'; -import { createTransport, Transporter } from 'nodemailer'; +import { createTransport, getTestMessageUrl, Transporter } from 'nodemailer'; import SMTPTransport from 'nodemailer/lib/smtp-transport'; import { PrismaService } from '../prisma.service'; import * as auditCreationEmail from './audit-creation-email'; +import * as accountVerificationEmail from './account-verification-email'; +import * as accountConfirmationEmail from './account-confirmation-email'; +import * as passwordUpdateConfirmationEmail from './password-update-confirmation-email'; +import * as updateEmailVerificationEmail from './update-email-verification-email'; +import * as updateEmailConfirmationEmail from './update-email-confirmation-email'; +import * as requestPasswordResetEmail from './request-password-reset-email'; import { EmailConfig } from './email-config.interface'; const EMAILS: Record = { [EmailType.AUDIT_CREATION]: auditCreationEmail, + [EmailType.ACCOUNT_VERIFICATION]: accountVerificationEmail, + [EmailType.ACCOUNT_CONFIRMATION]: accountConfirmationEmail, + [EmailType.PASSWORD_UPDATE_CONFIRMATION]: passwordUpdateConfirmationEmail, + [EmailType.EMAIL_UPDATE_VERIFICATION]: updateEmailVerificationEmail, + [EmailType.EMAIL_UPDATE_CONFIRMATION]: updateEmailConfirmationEmail, + [EmailType.PASSWORD_RESET_REQUEST]: requestPasswordResetEmail, }; @Injectable() @@ -50,6 +62,9 @@ export class MailService { text, html, }) + .then((info) => { + console.log(getTestMessageUrl(info)); + }) .catch((err) => { console.error('Failed to send email', err); emailStatus = EmailStatus.FAILURE; @@ -83,4 +98,54 @@ export class MailService { return this.sendMail(audit.auditorEmail, EmailType.AUDIT_CREATION, data); } + + sendAccountVerificationEmail(username: string, token: string) { + const baseUrl = this.config.get('FRONT_BASE_URL'); + + const verificationLink = `${baseUrl}/compte/validation?token=${encodeURIComponent( + token, + )}`; + + return this.sendMail(username, EmailType.ACCOUNT_VERIFICATION, { + verificationLink, + }); + } + + sendAccountConfirmationEmail(username: string) { + return this.sendMail(username, EmailType.ACCOUNT_CONFIRMATION, null); + } + + sendPasswordUpdateConfirmation(email: string) { + return this.sendMail(email, EmailType.PASSWORD_UPDATE_CONFIRMATION, null); + } + + sendNewEmailVerificationEmail(email: string, token: string) { + const baseUrl = this.config.get('FRONT_BASE_URL'); + + const verificationLink = `${baseUrl}/compte/email-update-validation?token=${encodeURIComponent( + token, + )}`; + + return this.sendMail(email, EmailType.EMAIL_UPDATE_VERIFICATION, { + verificationLink, + }); + } + + sendEmailUpdateConfirmationEmail(email: string) { + return this.sendMail(email, EmailType.EMAIL_UPDATE_CONFIRMATION, { + newEmail: email, + }); + } + + sendPasswordResetEmail(email: string, token: string) { + const baseUrl = this.config.get('FRONT_BASE_URL'); + + const verificationLink = `${baseUrl}/compte/reinitialiser-mot-de-passe?token=${encodeURIComponent( + token, + )}`; + + return this.sendMail(email, EmailType.PASSWORD_RESET_REQUEST, { + verificationLink, + }); + } } diff --git a/confiture-rest-api/src/mail/password-update-confirmation-email.ts b/confiture-rest-api/src/mail/password-update-confirmation-email.ts new file mode 100644 index 00000000..312860a8 --- /dev/null +++ b/confiture-rest-api/src/mail/password-update-confirmation-email.ts @@ -0,0 +1,19 @@ +import { renderMailTemplate } from './render-mjml-template'; + +export function subject(): string { + return `Ara : mot de passe mis à jour avec succès `; +} + +export function html(): string { + return renderMailTemplate('password-update-confirmation', {}); +} + +export function plain(): string { + return ` + Bonjour, + + Le mot de passe de votre compte Ara a bien été modifié. + + Vous avez une question ? Vous pouvez nous contacter en utilisant l’adresse e-mail ara@design.numerique.gouv.fr. + `; +} diff --git a/confiture-rest-api/src/mail/request-password-reset-email.ts b/confiture-rest-api/src/mail/request-password-reset-email.ts new file mode 100644 index 00000000..9c6e1367 --- /dev/null +++ b/confiture-rest-api/src/mail/request-password-reset-email.ts @@ -0,0 +1,24 @@ +import { renderMailTemplate } from './render-mjml-template'; + +export interface RequestPasswordResetEmailData { + verificationLink: string; +} + +export function subject(): string { + return `Ara : réinitialiser votre mot de passe `; +} + +export function html(data: RequestPasswordResetEmailData): string { + return renderMailTemplate('request-password-reset', data); +} + +export function plain(data: RequestPasswordResetEmailData): string { + return `Bonjour, + +Pour réinitialiser le mot de passe de votre compte Ara, veuillez cliquer sur le lien ci-dessous : + +${data.verificationLink} + +Vous avez une question ? Vous pouvez nous contacter en utilisant l’adresse e-mail ara@design.numerique.gouv.fr. +`; +} diff --git a/confiture-rest-api/src/mail/update-email-confirmation-email.ts b/confiture-rest-api/src/mail/update-email-confirmation-email.ts new file mode 100644 index 00000000..d5ad5834 --- /dev/null +++ b/confiture-rest-api/src/mail/update-email-confirmation-email.ts @@ -0,0 +1,22 @@ +import { renderMailTemplate } from './render-mjml-template'; + +export interface UpdateEmailConfirmationData { + newEmail: string; +} + +export function subject(): string { + return `Ara : adresse e-mail mise à jour avec succès`; +} + +export function html(data: UpdateEmailConfirmationData): string { + return renderMailTemplate('email-update-confirmation', data); +} + +export function plain(data: UpdateEmailConfirmationData): string { + return `Bonjour, + +L’adresse e-mail de votre compte Ara a bien été modifiée. La prochaine fois que vous vous connectez à votre compte, assurez-vous que vous utilisez l’adresse e-mail : ${data.newEmail}. + +Vous avez une question ? Vous pouvez nous contacter en utilisant l’adresse e-mail ara@design.numerique.gouv.fr. +`; +} diff --git a/confiture-rest-api/src/mail/update-email-verification-email.ts b/confiture-rest-api/src/mail/update-email-verification-email.ts new file mode 100644 index 00000000..a9cd34b8 --- /dev/null +++ b/confiture-rest-api/src/mail/update-email-verification-email.ts @@ -0,0 +1,24 @@ +import { renderMailTemplate } from './render-mjml-template'; + +export interface UpdateEmailVerificationData { + verificationLink: string; +} + +export function subject(): string { + return `Ara : vérification de la nouvelle adresse e-mail`; +} + +export function html(data: UpdateEmailVerificationData): string { + return renderMailTemplate('email-update-verification', data); +} + +export function plain(data: UpdateEmailVerificationData): string { + return `Bonjour, + +Pour finaliser le changement d’adresse e-mail associée à votre compte Ara, nous devons vérifier votre nouvelle adresse. Veuillez cliquer sur le bouton ci-dessous : + +${data.verificationLink} + +Vous avez une question ? Vous pouvez nous contacter en utilisant l’adresse e-mail ara@design.numerique.gouv.fr. +`; +} diff --git a/confiture-rest-api/src/profile/patch-profile.dto.ts b/confiture-rest-api/src/profile/patch-profile.dto.ts new file mode 100644 index 00000000..a04e0217 --- /dev/null +++ b/confiture-rest-api/src/profile/patch-profile.dto.ts @@ -0,0 +1,11 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class PatchProfileDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + orgName?: string; +} diff --git a/confiture-rest-api/src/profile/profile.controller.ts b/confiture-rest-api/src/profile/profile.controller.ts new file mode 100644 index 00000000..bd8ef4cf --- /dev/null +++ b/confiture-rest-api/src/profile/profile.controller.ts @@ -0,0 +1,35 @@ +import { Body, Controller, Patch, UnauthorizedException } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { AuthRequired } from '../auth/auth-required.decorator'; +import { AuthenticationJwtPayload } from '../auth/jwt-payloads'; +import { User } from '../auth/user.decorator'; +import { PatchProfileDto } from './patch-profile.dto'; +import { ProfileService } from './profile.service'; + +@Controller('profile') +@ApiTags('User Profile') +export class ProfileController { + constructor(private readonly profileService: ProfileService) {} + + /** + * Patch a user profile. + */ + @Patch() + @AuthRequired() + @ApiOkResponse({ + description: 'The profile has been successfully patched', + }) + async patchProfile( + @User() user: AuthenticationJwtPayload, + @Body() body: PatchProfileDto, + ) { + const userProfile = await this.profileService.patchProfile( + user.email, + body, + ); + + if (!userProfile) { + throw new UnauthorizedException(); + } + } +} diff --git a/confiture-rest-api/src/profile/profile.module.ts b/confiture-rest-api/src/profile/profile.module.ts new file mode 100644 index 00000000..e7bbe812 --- /dev/null +++ b/confiture-rest-api/src/profile/profile.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ProfileController } from './profile.controller'; +import { AuthModule } from 'src/auth/auth.module'; +import { ProfileService } from './profile.service'; +import { PrismaService } from 'src/prisma.service'; + +@Module({ + imports: [AuthModule], + providers: [ProfileService, PrismaService], + controllers: [ProfileController], +}) +export class ProfileModule {} diff --git a/confiture-rest-api/src/profile/profile.service.ts b/confiture-rest-api/src/profile/profile.service.ts new file mode 100644 index 00000000..9aa4dbe6 --- /dev/null +++ b/confiture-rest-api/src/profile/profile.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from 'src/prisma.service'; + +import { PatchProfileDto } from './patch-profile.dto'; + +@Injectable() +export class ProfileService { + constructor(private readonly prisma: PrismaService) {} + + async getUserProfile(userEmail: string) { + return this.prisma.user.findUnique({ + where: { username: userEmail }, + select: { id: true, username: true, name: true, orgName: true }, + }); + } + + async patchProfile(userEmail: string, body: PatchProfileDto) { + try { + const user = await this.prisma.user.update({ + where: { username: userEmail }, + data: { name: body.name, orgName: body.orgName }, + select: { id: true, username: true, name: true, orgName: true }, + }); + + return user; + } catch (e) { + // User does not exist + // https://www.prisma.io/docs/reference/api-reference/error-reference#p2025 + if (e?.code === 'P2025') { + return; + } + throw e; + } + } +} diff --git a/confiture-rest-api/templates/account-confirmation.mjml b/confiture-rest-api/templates/account-confirmation.mjml new file mode 100644 index 00000000..0843515e --- /dev/null +++ b/confiture-rest-api/templates/account-confirmation.mjml @@ -0,0 +1,34 @@ + + + Ara : compte créé avec succès + + Ara : compte créé avec succès + + + + + + + + + + + + Votre compte a été créé avec succès + + + Votre compte Ara a bien été créé. + + + + + + + + + + + + + + diff --git a/confiture-rest-api/templates/account-verification.mjml b/confiture-rest-api/templates/account-verification.mjml new file mode 100644 index 00000000..ce36434d --- /dev/null +++ b/confiture-rest-api/templates/account-verification.mjml @@ -0,0 +1,41 @@ + + + Ara : vérification du compte + + Ara : vérification du compte + + + + + + + + + + + + Finaliser la création de votre compte Ara + + + Pour finalisez la création de votre compte, veuillez cliquer sur le bouton ci-dessous : + + Valider mon adresse e-mail + + Ce lien est valable 1h + + + Si vous ne voulez pas créer de compte sur Ara ou si vous n’avez pas demandé à créer de compte, vous pouvez ignorer et supprimer cet e-mail. + + + + + + + + + + + + + + diff --git a/confiture-rest-api/templates/audit-creation.mjml b/confiture-rest-api/templates/audit-creation.mjml index 5bcb4894..c97d9413 100644 --- a/confiture-rest-api/templates/audit-creation.mjml +++ b/confiture-rest-api/templates/audit-creation.mjml @@ -13,7 +13,7 @@ - Bonjour {{ auditorName }} + Bonjour {{ auditorName }} Vous venez de créer l'audit {{ procedureName }}. diff --git a/confiture-rest-api/templates/email-update-confirmation.mjml b/confiture-rest-api/templates/email-update-confirmation.mjml new file mode 100644 index 00000000..8d1f7d87 --- /dev/null +++ b/confiture-rest-api/templates/email-update-confirmation.mjml @@ -0,0 +1,36 @@ + + + Ara : adresse e-mail mise à jour avec succès + + Ara : adresse e-mail mise à jour avec succès + + + + + + + + + + + + Adresse e-mail mise à jour avec succès + + + L’adresse e-mail de votre compte Ara a bien été modifiée. La prochaine + fois que vous vous connectez à votre compte, assurez-vous que vous + utilisez l’adresse e-mail : {{ newEmail }}. + + + + + + + + + + + + + + diff --git a/confiture-rest-api/templates/email-update-verification.mjml b/confiture-rest-api/templates/email-update-verification.mjml new file mode 100644 index 00000000..ef93ee12 --- /dev/null +++ b/confiture-rest-api/templates/email-update-verification.mjml @@ -0,0 +1,41 @@ + + + Ara : vérification de la nouvelle adresse e-mail + + Ara : vérification de la nouvelle adresse e-mail + + + + + + + + + + + + Changer l’adresse e-mail de votre compte + + + Pour finaliser le changement d’adresse e-mail associée à votre compte Ara, nous devons vérifier votre nouvelle adresse. Veuillez cliquer sur le bouton ci-dessous : + + Valider mon adresse e-mail + + Ce lien est valable 1h + + + Si vous ne voulez pas changer votre adresse e-mail ou si vous n’avez pas demandé à changer d’adresse e-mail, vous pouvez ignorer et supprimer cet e-mail. + + + + + + + + + + + + + + \ No newline at end of file diff --git a/confiture-rest-api/templates/footer.mjml b/confiture-rest-api/templates/footer.mjml index 02710514..35816eb8 100644 --- a/confiture-rest-api/templates/footer.mjml +++ b/confiture-rest-api/templates/footer.mjml @@ -1,9 +1,9 @@ - + Vous avez une question ? - Vous pouvez nous contacter en utilisant l’adresse e-mail rgaa@design.numerique.gouv.fr. + Vous pouvez nous contacter en utilisant l’adresse e-mail ara@design.numerique.gouv.fr. diff --git a/confiture-rest-api/templates/password-update-confirmation.mjml b/confiture-rest-api/templates/password-update-confirmation.mjml new file mode 100644 index 00000000..5da80259 --- /dev/null +++ b/confiture-rest-api/templates/password-update-confirmation.mjml @@ -0,0 +1,28 @@ + + + Ara : mot de passe mis à jour avec succès + + Ara : mot de passe mis à jour avec succès + + + + + + + + + + + + Mot de passe mis à jour avec succès + + + Le mot de passe de votre compte Ara a bien été modifié. + + + + + + + + \ No newline at end of file diff --git a/confiture-rest-api/templates/request-password-reset.mjml b/confiture-rest-api/templates/request-password-reset.mjml new file mode 100644 index 00000000..d0fee157 --- /dev/null +++ b/confiture-rest-api/templates/request-password-reset.mjml @@ -0,0 +1,46 @@ + + + Ara : réinitialiser votre mot de passe + + Ara : réinitialiser votre mot de passe + + + + + + + + + + + + Réinitialiser votre mot de passe + + + Pour changer le mot de passe de votre compte Ara, veuillez cliquer sur + le bouton ci-dessous : + + Choisir un nouveau mot de passe + + Ce lien est valable 1h + + + Si vous ne voulez pas changer votre mot de passe ou si vous n’avez pas + demandé à changer de mot de passe, vous pouvez ignorer et supprimer + cet e-mail. + + + + + + + + + + + + + + diff --git a/confiture-rest-api/templates/styles.mjml b/confiture-rest-api/templates/styles.mjml index d8528914..c660f692 100644 --- a/confiture-rest-api/templates/styles.mjml +++ b/confiture-rest-api/templates/styles.mjml @@ -1,4 +1,5 @@ - \ No newline at end of file + + \ No newline at end of file diff --git a/confiture-rest-api/yarn.lock b/confiture-rest-api/yarn.lock index 661ad6c0..a4e79fe8 100644 --- a/confiture-rest-api/yarn.lock +++ b/confiture-rest-api/yarn.lock @@ -1609,6 +1609,21 @@ resolved "https://registry.yarnpkg.com/@lukeed/csprng/-/csprng-1.1.0.tgz#1e3e4bd05c1cc7a0b2ddbd8a03f39f6e4b5e6cfe" integrity sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA== +"@mapbox/node-pre-gyp@^1.0.10": + version "1.0.10" + resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz#8e6735ccebbb1581e5a7e652244cadc8a844d03c" + integrity sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA== + dependencies: + detect-libc "^2.0.0" + https-proxy-agent "^5.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.7" + nopt "^5.0.0" + npmlog "^5.0.1" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.11" + "@nestjs/cli@^9.1.8": version "9.1.8" resolved "https://registry.yarnpkg.com/@nestjs/cli/-/cli-9.1.8.tgz#e4cb06c0cb628bf08ae143c2c6278a7beb38044b" @@ -1668,6 +1683,14 @@ path-to-regexp "3.2.0" tslib "2.5.0" +"@nestjs/jwt@^10.0.3": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@nestjs/jwt/-/jwt-10.0.3.tgz#e74e992cde99df266616c8bedf2404898eec4819" + integrity sha512-WO8MI3uEMOFKpbO+SAg6l4aRCr+9KvaL+raFMZaXuEUDphXek6pqdox+4tex9242pNSJUA0trfAMaiy/yVrXQg== + dependencies: + "@types/jsonwebtoken" "9.0.1" + jsonwebtoken "9.0.0" + "@nestjs/mapped-types@1.2.2": version "1.2.2" resolved "https://registry.yarnpkg.com/@nestjs/mapped-types/-/mapped-types-1.2.2.tgz#d9ddb143776e309dbc1a518ac1607fddac1e140e" @@ -2014,6 +2037,13 @@ dependencies: "@babel/types" "^7.3.0" +"@types/bcrypt@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@types/bcrypt/-/bcrypt-5.0.0.tgz#a835afa2882d165aff5690893db314eaa98b9f20" + integrity sha512-agtcFKaruL8TmcvqbndlqHPSJgsolhf/qPWchFlgnW1gECTN/nKbFcoFnvKAQRFfKbh+BO6A3SWdJu9t+xF3Lw== + dependencies: + "@types/node" "*" + "@types/body-parser@*": version "1.19.2" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0" @@ -2167,6 +2197,13 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/jsonwebtoken@9.0.1": + version "9.0.1" + resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.1.tgz#29b1369c4774200d6d6f63135bf3d1ba3ef997a4" + integrity sha512-c5ltxazpWabia/4UzhIoaDcIza4KViOQhdbjRlfcIGVnsE3c3brkz9Z+F/EeJIECOQP7W7US2hNE930cWWkPiw== + dependencies: + "@types/node" "*" + "@types/keyv@*": version "3.1.4" resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.4.tgz#3ccdb1c6751b0c7e52300bcdacd5bcbf8faa75b6" @@ -2174,14 +2211,7 @@ dependencies: "@types/node" "*" -"@types/lodash-es@^4.17.7": - version "4.17.7" - resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.7.tgz#22edcae9f44aff08546e71db8925f05b33c7cc40" - integrity sha512-z0ptr6UI10VlU6l5MYhGwS4mC8DZyYer2mCoyysZtSF7p26zOX8UpbrV0YpNYLGS8K4PUFIyEr62IMFFjveSiQ== - dependencies: - "@types/lodash" "*" - -"@types/lodash@*", "@types/lodash@^4.14.194": +"@types/lodash@^4.14.194": version "4.14.194" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.194.tgz#b71eb6f7a0ff11bff59fc987134a093029258a76" integrity sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g== @@ -2544,7 +2574,7 @@ abab@^2.0.3, abab@^2.0.5: resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== -abbrev@^1.0.0: +abbrev@1, abbrev@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== @@ -2696,6 +2726,11 @@ append-field@^1.0.0: resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw== +"aproba@^1.0.3 || ^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" + integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== + archiver-utils@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-2.1.0.tgz#e8a460e94b693c3e3da182a098ca6285ba9249e2" @@ -2725,6 +2760,14 @@ archiver@5.3.1: tar-stream "^2.2.0" zip-stream "^4.1.0" +are-we-there-yet@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" + integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== + dependencies: + delegates "^1.0.0" + readable-stream "^3.6.0" + arg@5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.1.tgz#eb0c9a8f77786cad2af8ff2b862899842d7b6adb" @@ -2855,6 +2898,14 @@ basic-auth@~2.0.1: dependencies: safe-buffer "5.1.2" +bcrypt@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.1.0.tgz#bbb27665dbc400480a524d8991ac7434e8529e17" + integrity sha512-RHBS7HI5N5tEnGTmtR/pppX0mmDSBpQ4aCBsj7CEQfYXDcO74A8sIBYcJMuCsis2E81zDxeENYhv66oZwLiA+Q== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.10" + node-addon-api "^5.0.0" + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" @@ -2971,6 +3022,11 @@ buffer-crc32@^0.2.1, buffer-crc32@^0.2.13: resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" @@ -3308,6 +3364,11 @@ color-string@^1.9.0: color-name "^1.0.0" simple-swizzle "^0.2.2" +color-support@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" + integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== + color@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" @@ -3394,6 +3455,11 @@ consola@^2.15.0: resolved "https://registry.yarnpkg.com/consola/-/consola-2.15.3.tgz#2e11f98d6a4be71ff72e0bdf07bd23e12cb61550" integrity sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw== +console-control-strings@^1.0.0, console-control-strings@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== + content-disposition@0.5.4: version "0.5.4" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" @@ -3608,6 +3674,11 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== + depd@2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" @@ -3754,6 +3825,13 @@ dotenv@16.0.3: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.3.tgz#115aec42bac5053db3c456db30cc243a5a836a07" integrity sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ== +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + editorconfig@^0.15.3: version "0.15.3" resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5" @@ -4379,6 +4457,21 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g== +gauge@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" + integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== + dependencies: + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.2" + console-control-strings "^1.0.0" + has-unicode "^2.0.1" + object-assign "^4.1.1" + signal-exit "^3.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.2" + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -4542,6 +4635,11 @@ has-symbols@^1.0.3: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== +has-unicode@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ== + has-yarn@2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77" @@ -5512,6 +5610,16 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonwebtoken@9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz#d0faf9ba1cc3a56255fe49c0961a67e520c1926d" + integrity sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw== + dependencies: + jws "^3.2.2" + lodash "^4.17.21" + ms "^2.1.1" + semver "^7.3.8" + juice@^9.0.0: version "9.0.0" resolved "https://registry.yarnpkg.com/juice/-/juice-9.0.0.tgz#8aed857a896f27f8063e6fba7ecdcd019b4e300c" @@ -5523,6 +5631,23 @@ juice@^9.0.0: slick "^1.12.2" web-resource-inliner "^6.0.1" +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + keyv@^4.0.0: version "4.3.3" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.3.3.tgz#6c1bcda6353a9e96fc1b4e1aeb803a6e35090ba9" @@ -5593,11 +5718,6 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@^4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" - integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== - lodash.defaults@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" @@ -6210,7 +6330,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3: +ms@2.1.3, ms@^2.1.1: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -6301,7 +6421,7 @@ node-fetch@2.6.7, node-fetch@^2.6.1: dependencies: whatwg-url "^5.0.0" -node-fetch@^2.6.0: +node-fetch@^2.6.0, node-fetch@^2.6.7: version "2.6.9" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" integrity sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg== @@ -6323,6 +6443,13 @@ nodemailer@^6.7.7: resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.7.tgz#e522fbd7507b81c51446d3f79c4603bf00083ddd" integrity sha512-pOLC/s+2I1EXuSqO5Wa34i3kXZG3gugDssH+ZNCevHad65tc8vQlCQpOLaUjopvkRQKm2Cki2aME7fEOPRy3bA== +nopt@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" + integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== + dependencies: + abbrev "1" + nopt@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/nopt/-/nopt-6.0.0.tgz#245801d8ebf409c6df22ab9d95b65e1309cdb16d" @@ -6357,6 +6484,16 @@ npm-run-path@^4.0.0, npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" +npmlog@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" + integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== + dependencies: + are-we-there-yet "^2.0.0" + console-control-strings "^1.1.0" + gauge "^3.0.0" + set-blocking "^2.0.0" + nth-check@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" @@ -7137,6 +7274,11 @@ serve-static@1.15.0: parseurl "~1.3.3" send "0.18.0" +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== + setprototypeof@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" @@ -7196,7 +7338,7 @@ sigmund@^1.0.1: resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" integrity sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g== -signal-exit@^3.0.2, signal-exit@^3.0.3: +signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== @@ -7332,7 +7474,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -string-width@4.2.3, string-width@^4.1.0, string-width@^4.2.0: +string-width@4.2.3, "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -8116,6 +8258,13 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +wide-align@^1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" + integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== + dependencies: + string-width "^1.0.2 || 2 || 3 || 4" + windows-release@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-4.0.0.tgz#4725ec70217d1bf6e02c7772413b29cdde9ec377" diff --git a/confiture-web-app/package.json b/confiture-web-app/package.json index 9c44f8e2..948f4792 100644 --- a/confiture-web-app/package.json +++ b/confiture-web-app/package.json @@ -18,6 +18,7 @@ "@unhead/vue": "^1.5.3", "chart.js": "^4.1.0", "dompurify": "^2.4.1", + "jwt-decode": "^3.1.2", "ky": "^0.33.0", "lodash-es": "^4.17.21", "marked": "^4.2.4", diff --git a/confiture-web-app/src/App.vue b/confiture-web-app/src/App.vue index 5799f93f..801d9b4d 100644 --- a/confiture-web-app/src/App.vue +++ b/confiture-web-app/src/App.vue @@ -1,5 +1,5 @@