Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions front/app/(visualiser)/interpeller/error/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Image from 'next/image';
import Link from 'next/link';

import { Button } from '#components/ui/button';

export default function InterpellateError() {
return (
<section className='global-margin mb-16 mt-16'>
<article className='mx-4 rounded-3xl border border-primary-light pb-12 shadow'>
<div
id='header-article'
className='align-center mb-16 rounded-t-3xl bg-[url(/eclaireur/project_background.webp)] bg-bottom px-8 py-12 md:flex-row md:gap-0'
>
<h2 className='text-center'>Lien invalide ou expiré</h2>
</div>

<Image
src='/eclaireur/error_icon.png'
alt='Erreur'
width={100}
height={100}
className='mx-auto block'
/>
<p className='mb-12 mt-6 px-8 text-center text-lg'>
Ce lien de confirmation n'est plus valide.
<br />
Il a peut-être expiré ou a déjà été utilisé.
</p>

<Button
size='lg'
className='mx-auto block rounded-none rounded-br-lg rounded-tl-lg bg-primary hover:bg-primary/90'
>
<Link href='/interpeller'>
<Image
src='/eclaireur/interpeller.svg'
alt='Interpeller'
width={20}
height={20}
className='mr-2 inline-block'
/>
Retourner à l'interpellation
</Link>
</Button>
</article>
</section>
);
}
32 changes: 20 additions & 12 deletions front/app/api/interpellate/confirm/route.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
import { redirect } from 'next/navigation';

import { ConfirmInterpellateSubject, getCommunityTitle, getFullName } from '#utils/emails/emailRendering';
import {
ConfirmInterpellateSubject,
getCommunityTitle,
getFullName,
} from '#utils/emails/emailRendering';
import { renderEmailTemplate } from '#utils/emails/emailRendering-server';
import { verifyInterpellateToken } from '#utils/emails/interpellateToken';
import { trySendMail } from '#utils/emails/send-email';
import Mail from 'nodemailer/lib/mailer';


export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const siren = searchParams.get('siren');
const communityType = searchParams.get('communityType') ?? '';
const communityName = searchParams.get('communityName') ?? '';
const firstname = searchParams.get('firstname') ?? '';
const lastname = searchParams.get('lastname') ?? '';
// mail en CC
const email = searchParams.get('email') ?? '';
// Mails à interpeller
const emails = searchParams.get('emails') ?? '';
const isCC = searchParams.get('isCC') === 'true';
const token = searchParams.get('token');

if (!token) {
redirect('/interpeller/error');
}

const payload = verifyInterpellateToken(token);

if (!payload) {
redirect('/interpeller/error');
}

const { siren, communityType, communityName, firstname, lastname, email, emails, isCC } =
payload;

const confirmInterpellateHtml = renderEmailTemplate('interpellate-community', {
communityTitle: getCommunityTitle(communityType),
Expand Down
27 changes: 13 additions & 14 deletions front/app/api/interpellate/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
// TODO: Review and remove unused variables. This file ignores unused vars for now.
/* eslint-disable @typescript-eslint/no-unused-vars */
import { NextResponse } from 'next/server';

import { InterpellateFormSchema } from '#components/Interpellate/types';
import { renderEmailTemplate } from '#utils/emails/emailRendering-server';
import { createInterpellateToken } from '#utils/emails/interpellateToken';
import { trySendMail } from '#utils/emails/send-email';
import Mail from 'nodemailer/lib/mailer';

Expand Down Expand Up @@ -45,20 +44,20 @@ export async function POST(request: Request) {
return NextResponse.json({ errors: zodErrors });
}

const confirmUrl = new URL('api/interpellate/confirm', process.env.NEXT_PUBLIC_BASE_URL);
const params = new URLSearchParams();
params.append('siren', siren ?? '');
params.append('isCC', isCC !== undefined ? isCC.toString() : 'false');
params.append('firstname', firstname ?? '');
params.append('lastname', lastname ?? '');
params.append('email', email ?? '');
params.append('emails', emails ?? '');
params.append('communityType', communityType ?? '');
params.append('communityName', communityName ?? '');
confirmUrl.search = params.toString();
const token = createInterpellateToken({
siren: siren ?? '',
firstname: firstname ?? '',
lastname: lastname ?? '',
email: email ?? '',
emails: emails ?? '',
isCC: isCC ?? false,
communityType: communityType ?? '',
communityName: communityName ?? '',
});
const confirmUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/api/interpellate/confirm?token=${token}`;
const confirmInterpellateHtml = renderEmailTemplate('confirm-interpellate', {
firstname: firstname ?? '',
link: confirmUrl.toString(),
link: confirmUrl,
});

const confirmInterpellateHtmlObject = `${firstname}, Confirmez votre interpellation citoyenne - Éclaireur Public`;
Expand Down
66 changes: 66 additions & 0 deletions front/utils/emails/interpellateToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import crypto from 'crypto';

export interface InterpellateTokenPayload {
siren: string;
firstname: string;
lastname: string;
email: string;
emails: string;
isCC: boolean;
communityType: string;
communityName: string;
exp: number;
}

function getSecret(): string {
const secret = process.env.INTERPELLATE_SECRET;
if (!secret) {
throw new Error('INTERPELLATE_SECRET environment variable is not set');
}
return secret;
}

export function createInterpellateToken(
data: Omit<InterpellateTokenPayload, 'exp'>,
expiresInMinutes: number = 1440
): string {
const payload: InterpellateTokenPayload = {
...data,
exp: Date.now() + expiresInMinutes * 60 * 1000,
};

const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = crypto.createHmac('sha256', getSecret()).update(encoded).digest('base64url');

return `${encoded}.${signature}`;
}

export function verifyInterpellateToken(token: string): InterpellateTokenPayload | null {
const parts = token.split('.');
if (parts.length !== 2) {
return null;
}

const [encoded, signature] = parts;

const expectedSignature = crypto
.createHmac('sha256', getSecret())
.update(encoded)
.digest('base64url');

if (signature !== expectedSignature) {
return null;
}

try {
const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf-8'));

if (payload.exp < Date.now()) {
return null;
}

return payload as InterpellateTokenPayload;
} catch {
return null;
}
}