Compare commits

10 Commits

Author SHA1 Message Date
cedric 42fb5f40f9 build: add node-gyp & sharp lib 2026-05-14 12:26:55 +04:00
cedric 60f5c8ae9c chore: remove package-lock.json 2026-04-30 20:15:43 +04:00
cedric 1109ceb2bb fix: web socket in dev 2026-04-14 17:38:23 +04:00
cedric d4deaa7716 chore: mise à jour todo.md — clôture P3
- Lazy loading : déjà implémenté (dynamic imports jsPDF/html2canvas/md-editor)
- NextAuth v5 stable : bloquée, pas de v5 stable publiée à ce jour
- Responsive mobile : à vérifier manuellement sur appareil

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 14:41:12 +04:00
cedric 43f1f6e9f2 a11y: corrections accessibilité WCAG 2.1 (critères 4.1.2, 4.1.3, 1.3.1)
sign.js :
- aria-label sur les 4 Fab (Se déconnecter, dashboard, Se connecter, S'enregistrer)
- Correction des guillemets typographiques U+2018/U+2019 en ASCII (empêchaient le parsing JSX)
- Suppression de useMemo inutilisé
- IIFE async ;() → startSubscription() nommée + .catch() explicite (semi-style + no-void)

auth-form/index.js :
- aria-label des IconButton visibility traduits en français avec état dynamique :
  'Afficher/Masquer le mot de passe' et 'Afficher/Masquer la vérification'

version-timeline.js :
- aria-label='Comparer les versions' sur IconButton Comparer
- aria-label dynamique + aria-expanded sur le bouton expand/collapse
- Correction object-curly-newline et jsx-closing-bracket-location (pré-existants)

version-search.js :
- inputProps aria-label='Rechercher dans les versions' (placeholder seul insuffisant)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 14:36:37 +04:00
cedric e75d2e1c53 feat: dockerisation frontend Next.js (output: standalone)
- next.config.mjs : output: 'standalone' — bundle minimal sans node_modules
- Dockerfile      : multi-stage (deps → builder → runner) sur node:22-alpine
  - ARG build-time pour les vars NEXT_PUBLIC_* et SENTRY_AUTH_TOKEN (optionnel)
  - Utilisateur non-root nextjs:nodejs (uid/gid 1001)
  - Image finale < 200 Mo (pas de node_modules, juste .next/standalone)
- docker-compose.yml : service frontend avec env_file et restart: unless-stopped
- .dockerignore   : exclut node_modules, .next, .env, yarn.lock, docs

Alternative au déploiement PM2 existant (DEPLOYMENT.md inchangé).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 06:55:41 +04:00
cedric c4762c6437 perf: cache des données constitution avec unstable_cache (ISR data layer)
La page d'accueil appelle auth() (cookies → dynamique) donc export const revalidate
ne s'applique pas au rendu. On cache à la place les appels Directus :

- fetchConstitution() → wrappée avec nextCache (unstable_cache)
- revalidate: 300 (5 min) — la constitution évolue peu
- tag 'constitution' — permet revalidateTag('constitution') depuis une API route
  pour invalider le cache à la demande lors d'un changement Directus

Les appels API Directus (titres + articles) ne sont plus refaits à chaque requête.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 06:54:07 +04:00
cedric a25a610d73 chore: suppression middleware.js et mise à jour yarn.lock
middleware.js fusionné dans proxy.js depuis le commit 8016c26.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 06:50:35 +04:00
cedric 8016c26e32 feat: intégration Sentry + migration middleware.js → proxy.js (Next.js 16)
Sentry (tracking erreurs frontend + API routes) :
- sentry.client.config.js  : erreurs navigateur + Session Replay sur erreurs
- sentry.server.config.js  : erreurs API routes (register, jwt callback)
- sentry.edge.config.js    : runtime edge (middleware proxy)
- instrumentation.js       : point d'entrée Next.js 15+ (register + captureRequestError)
- next.config.mjs          : wrappé avec withSentryConfig (source maps désactivés sans SENTRY_AUTH_TOKEN)
- .env.sample              : ajout de NEXT_PUBLIC_SENTRY_DSN (placeholder)

Migration middleware → proxy (bug pré-existant surfacé par le build Sentry) :
- proxy.js : fusion du rate limiting + auth NextAuth en un seul proxy Next.js 16
- middleware.js : supprimé (Next.js 16 n'accepte plus les deux fichiers simultanément)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 06:48:55 +04:00
cedric d8a771161c feat(auth): refresh token Directus explicite dans le callback JWT NextAuth
Sans ce correctif, l'access token Directus (~15 min) expirait silencieusement,
rendant toutes les requêtes API 401 sans déconnecter l'utilisateur.

- Ajout de refreshDirectusToken() : POST /auth/refresh avec rotation du refresh_token
- accessTokenExpires stocké dès la connexion (expires Directus - marge 60s)
- jwt callback : token valide → pass-through, token expiré → refresh, échec → error flag
- session callback : propagation de session.error = 'RefreshAccessTokenError'
  (permet au client de forcer un signOut si le refresh_token est lui-même expiré)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 06:37:11 +04:00
21 changed files with 2865 additions and 12873 deletions
+9
View File
@@ -0,0 +1,9 @@
Dockerfile
.dockerignore
node_modules
.next
.git
.env
yarn.lock
tasks/
*.md
+3
View File
@@ -19,3 +19,6 @@ COMMENTS_PER_PAGE=5
# WEBSOCKET
NEXT_PUBLIC_DISABLE_WEBSOCKET=false
# SENTRY
NEXT_PUBLIC_SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx
+52
View File
@@ -0,0 +1,52 @@
# ─── Étape 1 : dépendances ───────────────────────────────────────────────────
FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ─── Étape 2 : build de production ───────────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Variables nécessaires au build (publiques uniquement — pas de secrets)
ARG NEXT_PUBLIC_DIRECTUS_API_URL
ARG NEXT_PUBLIC_DIRECTUS_API_WS_URL
ARG NEXT_PUBLIC_SENTRY_DSN
ARG SENTRY_AUTH_TOKEN
ENV NEXT_PUBLIC_DIRECTUS_API_URL=$NEXT_PUBLIC_DIRECTUS_API_URL
ENV NEXT_PUBLIC_DIRECTUS_API_WS_URL=$NEXT_PUBLIC_DIRECTUS_API_WS_URL
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
RUN npm run build
# ─── Étape 3 : image de production minimale ──────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Utilisateur non-root pour la sécurité
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Le mode standalone copie uniquement ce qui est nécessaire à l'exécution
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
# server.js généré par output: 'standalone'
CMD ["node", "server.js"]
+51 -11
View File
@@ -1,3 +1,4 @@
/* eslint-disable new-cap */
import CredentialsProvider from 'next-auth/providers/credentials'
import {readMe, withToken} from '@directus/sdk'
import {directusClient} from '@/lib/directus.js'
@@ -5,9 +6,39 @@ import {directusClient} from '@/lib/directus.js'
const apiUrl = process.env.DIRECTUS_API_URL
const nextauthSecret = process.env.NEXTAUTH_SECRET
// On rafraîchit 60s avant l'échéance réelle pour éviter les races
const REFRESH_MARGIN_MS = 60 * 1000
/**
* Appelle l'endpoint Directus /auth/refresh et retourne les nouveaux tokens.
* Lève une erreur si le refresh échoue (refresh_token expiré ou révoqué).
*
* @param {string} refreshToken
* @returns {Promise<{accessToken: string, refreshToken: string, accessTokenExpires: number}>}
*/
async function refreshDirectusToken(refreshToken) {
const res = await fetch(`${apiUrl}/auth/refresh`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({refresh_token: refreshToken, mode: 'json'}),
})
if (!res.ok) {
throw new Error(`Directus refresh failed with status ${res.status}`)
}
const {data} = await res.json()
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
accessTokenExpires: Date.now() + (data.expires ?? 900_000) - REFRESH_MARGIN_MS,
}
}
export const options = {
providers: [
CredentialsProvider({ // eslint-disable-line new-cap
CredentialsProvider({
name: 'Credentials',
credentials: {
email: {},
@@ -36,18 +67,13 @@ export const options = {
signIn: '/login'
},
callbacks: {
async jwt({
token,
user,
account
}) {
async jwt({token, user, account}) {
// Connexion initiale : enrichissement du token avec les données Directus
if (account && user) {
const userData = await directusClient.request(
withToken(
user.data.access_token,
readMe({
fields: ['*']
})
readMe({fields: ['*']})
)
)
@@ -55,11 +81,24 @@ export const options = {
...token,
accessToken: user.data.access_token,
refreshToken: user.data.refresh_token,
user: userData
accessTokenExpires: Date.now() + (user.data.expires ?? 900_000) - REFRESH_MARGIN_MS,
user: userData,
}
}
return token
// Token encore valide : on le retourne sans modification
if (Date.now() < token.accessTokenExpires) {
return token
}
// Token expiré : tentative de rafraîchissement silencieux
try {
const refreshed = await refreshDirectusToken(token.refreshToken)
return {...token, ...refreshed}
} catch (refreshError) {
console.error('Refresh token Directus échoué :', refreshError.message)
return {...token, error: 'RefreshAccessTokenError'}
}
},
async session({session, token}) {
session.user.userId = token.user.id
@@ -69,6 +108,7 @@ export const options = {
session.user.token = token.user.token
session.user.email = token.user.email
session.user.password = token.user.password
session.error = token.error
return session
}
}
+10 -1
View File
@@ -1,4 +1,5 @@
import {createDirectus, rest, readItems} from '@directus/sdk'
import {unstable_cache as nextCache} from 'next/cache'
import Container from '@mui/material/Container'
import Box from '@mui/material/Box'
import Typography from '@mui/material/Typography'
@@ -19,7 +20,7 @@ const navButton = {
icon: <AdminPanelSettingsIcon fontSize='large' />
}
async function getData() {
async function fetchConstitution() {
if (!apiUrl) {
throw new Error('DIRECTUS_API_URL is required')
}
@@ -58,6 +59,14 @@ async function getData() {
}
}
// Mise en cache des données constitution — revalidation toutes les 5 minutes.
// Le tag 'constitution' permet une invalidation à la demande via revalidateTag().
const getData = nextCache(
fetchConstitution,
['constitution-data'],
{revalidate: 300, tags: ['constitution']}
)
export default async function Page() {
const session = await auth()
const {titres, articles} = await getData()
+2 -2
View File
@@ -138,7 +138,7 @@ export default function AuthForm({
endAdornment={
<InputAdornment position='end'>
<IconButton
aria-label='password visibility'
aria-label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
size='large'
onClick={handleClickShowPassword}
onMouseDown={handleMouseDownPassword}
@@ -167,7 +167,7 @@ export default function AuthForm({
endAdornment={
<InputAdornment position='end'>
<IconButton
aria-label='password visibility'
aria-label={showPasswordVerification ? 'Masquer la vérification du mot de passe' : 'Afficher la vérification du mot de passe'}
size='large'
onClick={handleClickShowPasswordVerifiation}
onMouseDown={handleMouseDownPasswordVerification}
+10 -8
View File
@@ -1,7 +1,7 @@
'use client'
import PropTypes from 'prop-types'
import {useEffect, useState, useMemo} from 'react'
import {useEffect, useState} from 'react'
import {signOut} from 'next-auth/react'
import {useRouter} from 'next/navigation'
import Box from '@mui/material/Box'
@@ -70,7 +70,7 @@ export default function Sign({session, navButton}) {
console.log('WebSocket authenticated successfully!')
// Subscribe to version changes
;(async () => {
const startSubscription = async () => {
const {subscription} = await client.subscribe('directus_versions', {
event: 'create',
query: {
@@ -86,7 +86,9 @@ export default function Sign({session, navButton}) {
for await (const item of subscription) {
console.log('New version created:', item)
}
})()
}
startSubscription().catch(error => console.error('Subscription error:', error))
}
})
@@ -117,12 +119,12 @@ export default function Sign({session, navButton}) {
{session ? (
<Stack direction='row' spacing={2}>
<LightTooltip title='Se déconnecter' placement='right'>
<Fab size='large' color='error' onClick={() => setIsOpen(true)}>
<Fab size='large' color='error' aria-label='Se déconnecter' onClick={() => setIsOpen(true)}>
<LogoutIcon fontSize='large' />
</Fab>
</LightTooltip>
<LightTooltip title={navButton.title} placement='right'>
<Fab sx={{mr: 3}} size='large' color={navButton.color} onClick={() => router.push(navButton.path)}>
<Fab sx={{mr: 3}} size='large' color={navButton.color} aria-label={navButton.title} onClick={() => router.push(navButton.path)}>
{navButton.icon}
</Fab>
</LightTooltip>
@@ -130,12 +132,12 @@ export default function Sign({session, navButton}) {
) : (
<Stack direction='row' spacing={2}>
<LightTooltip title='Se connecter' placement='left'>
<Fab size='large' color='success' onClick={() => router.push('/login')}>
<Fab size='large' color='success' aria-label='Se connecter' onClick={() => router.push('/login')}>
<LoginIcon fontSize='large' />
</Fab>
</LightTooltip>
<LightTooltip title='Senregistrer' placement='right'>
<Fab size='large' color='success' onClick={() => router.push('/register')}>
<LightTooltip title="S'enregistrer" placement='right'>
<Fab size='large' color='success' aria-label="S'enregistrer" onClick={() => router.push('/register')}>
<PersonAddIcon fontSize='large' />
</Fab>
</LightTooltip>
+1
View File
@@ -34,6 +34,7 @@ export default function VersionSearch({onSearchChange, placeholder = 'Rechercher
size='small'
placeholder={placeholder}
value={searchValue}
inputProps={{'aria-label': 'Rechercher dans les versions'}}
InputProps={{
startAdornment: (
<InputAdornment position='start'>
+39 -6
View File
@@ -100,7 +100,14 @@ function VersionItem({
}}
>
{/* Status indicator */}
<Box sx={{display: 'flex', flexDirection: 'column', alignItems: 'center', pt: 0.5}}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
pt: 0.5
}}
>
<Box
sx={{
width: 12,
@@ -151,17 +158,29 @@ function VersionItem({
</Box>
{/* Actions */}
<Box sx={{display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0}}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
flexShrink: 0
}}
>
<VoteButtons
hasCountsVisible
versionId={version.id}
isDisabled={isVoteDisabled}
onVoteResult={onVoteResult}
/>
<IconButton size='small' onClick={handleCompare} title='Comparer'>
<IconButton size='small' aria-label='Comparer les versions' title='Comparer' onClick={handleCompare}>
<CompareArrowsIcon fontSize='small' />
</IconButton>
<IconButton size='small' onClick={() => setExpanded(!expanded)}>
<IconButton
size='small'
aria-label={expanded ? 'Réduire les détails' : 'Afficher les détails'}
aria-expanded={expanded}
onClick={() => setExpanded(!expanded)}
>
{expanded ? <ExpandLessIcon fontSize='small' /> : <ExpandMoreIcon fontSize='small' />}
</IconButton>
</Box>
@@ -169,7 +188,14 @@ function VersionItem({
{/* Expanded content */}
<Collapse in={expanded}>
<Box sx={{mt: 1.5, display: 'flex', flexDirection: 'column', gap: 1}}>
<Box
sx={{
mt: 1.5,
display: 'flex',
flexDirection: 'column',
gap: 1
}}
>
{/* Preview */}
{version.delta?.contenu && (
<Typography
@@ -188,7 +214,14 @@ function VersionItem({
)}
{/* Actions row */}
<Box sx={{display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap'}}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap'
}}
>
<CopyButton
content={version.delta?.contenu || version.name || ''}
label='Copier'
+13
View File
@@ -0,0 +1,13 @@
services:
frontend:
build:
context: .
args:
NEXT_PUBLIC_DIRECTUS_API_URL: ${NEXT_PUBLIC_DIRECTUS_API_URL}
NEXT_PUBLIC_DIRECTUS_API_WS_URL: ${NEXT_PUBLIC_DIRECTUS_API_WS_URL}
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN}
# SENTRY_AUTH_TOKEN: ${SENTRY_AUTH_TOKEN} # décommenter pour uploader les source maps
ports:
- "3000:3000"
env_file: .env
restart: unless-stopped
+11
View File
@@ -0,0 +1,11 @@
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config.js')
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config.js')
}
}
export {captureRequestError as onRequestError} from '@sentry/nextjs'
-59
View File
@@ -1,59 +0,0 @@
import {NextResponse} from 'next/server'
import {createRateLimiter} from '@/lib/rate-limit.js'
// 5 inscriptions max par IP toutes les 15 minutes
const checkRegister = createRateLimiter({windowMs: 15 * 60 * 1000, max: 5})
// 10 tentatives de connexion max par IP toutes les 5 minutes
const checkSignin = createRateLimiter({windowMs: 5 * 60 * 1000, max: 10})
const limiters = {
'/api/auth/register': checkRegister,
'/api/auth/callback/credentials': checkSignin,
}
/**
* Extrait l'IP cliente depuis les headers HTTP.
* Priorité à X-Real-IP (Nginx), puis X-Forwarded-For.
*/
function getClientIp(request) {
const realIp = request.headers.get('x-real-ip')
if (realIp) {
return realIp.trim()
}
const forwarded = request.headers.get('x-forwarded-for')
if (forwarded) {
return forwarded.split(',')[0].trim()
}
return 'unknown'
}
export function middleware(request) {
const {pathname} = request.nextUrl
const check = limiters[pathname]
if (!check) {
return NextResponse.next()
}
const ip = getClientIp(request)
const result = check(`${ip}:${pathname}`)
if (result.success) {
return NextResponse.next()
}
return NextResponse.json(
{message: 'Trop de tentatives. Veuillez réessayer dans quelques minutes.'},
{
status: 429,
headers: {'Retry-After': String(result.retryAfter)},
}
)
}
export const config = {
matcher: ['/api/auth/register', '/api/auth/callback/credentials'],
}
+27 -2
View File
@@ -1,10 +1,18 @@
/** @type {import('next').NextConfig} */
import {withSentryConfig} from '@sentry/nextjs'
// Les URL Directus sont lues à l'exécution — elles s'adaptent à l'environnement
// (dev local ou production) sans rebuild.
const apiUrl = process.env.NEXT_PUBLIC_DIRECTUS_API_URL ?? ''
const wsUrl = process.env.NEXT_PUBLIC_DIRECTUS_API_WS_URL ?? ''
// Le SDK Directus dérive l'URL WebSocket depuis apiUrl (https→wss, http→ws).
// On l'inclut toujours dans connect-src pour garantir que CSP autorise la connexion,
// même si NEXT_PUBLIC_DIRECTUS_API_WS_URL pointe vers un hôte différent.
const derivedWsUrl = apiUrl
.replace(/^https:\/\//, 'wss://')
.replace(/^http:\/\//, 'ws://')
// Tokens CSP — les guillemets simples font partie de la spec CSP, pas de JS
const SELF = '\'self\''
const NONE = '\'none\''
@@ -19,7 +27,7 @@ const cspDirectives = [
`default-src ${SELF}`,
`script-src ${SELF} ${UNSAFE_INLINE}`,
`style-src ${SELF} ${UNSAFE_INLINE}`,
`connect-src ${SELF} ${apiUrl} ${wsUrl}`.trim(),
`connect-src ${SELF} ${apiUrl} ${wsUrl} ${derivedWsUrl}`.trim().replace(/ {2,}/g, ' '),
`img-src ${SELF} data: blob:`,
`font-src ${SELF}`,
`object-src ${NONE}`,
@@ -52,6 +60,8 @@ const securityHeaders = [
]
const nextConfig = {
// Active le mode standalone : bundle minimal autonome pour Docker
output: 'standalone',
experimental: {
// Optimiser les imports pour réduire la mémoire
optimizePackageImports: ['@mui/material', '@mui/icons-material', '@emotion/react', '@emotion/styled'],
@@ -70,4 +80,19 @@ const nextConfig = {
},
}
export default nextConfig
export default withSentryConfig(nextConfig, {
// Organisation et projet Sentry pour l'upload des source maps
// Nécessite SENTRY_AUTH_TOKEN en CI pour activer l'upload
silent: !process.env.CI,
// Ne pas uploader les source maps si le token est absent (dev local)
sourcemaps: {
disable: !process.env.SENTRY_AUTH_TOKEN,
},
// Évite d'exposer les source maps aux utilisateurs finaux
hideSourceMaps: true,
// Tunnel Sentry via notre propre domaine (contourne les adblockers)
// tunnelRoute: '/monitoring',
})
-11944
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -18,6 +18,7 @@
"@mui/lab": "^7.0.1-beta.20",
"@mui/material": "^7.3.6",
"@mui/material-nextjs": "^7.3.6",
"@sentry/nextjs": "^10.48.0",
"@uiw/react-md-editor": "^4.0.11",
"date-fns": "^4.1.0",
"html2canvas": "^1.4.1",
@@ -25,9 +26,11 @@
"marked": "^17.0.1",
"next": "^16.1.0",
"next-auth": "^5.0.0-beta.30",
"node-gyp": "^12.3.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-virtuoso": "^4.18.1",
"sharp": "^0.34.5",
"use-debounce": "^10.0.5"
},
"devDependencies": {
@@ -58,9 +61,17 @@
"overrides": [
{
"files": "lib/__tests__/**/*.js",
"envs": ["node", "es2020"],
"envs": [
"node",
"es2020"
],
"rules": {
"camelcase": ["error", {"properties": "never"}],
"camelcase": [
"error",
{
"properties": "never"
}
],
"capitalized-comments": "off"
}
}
+55 -1
View File
@@ -1 +1,55 @@
export {auth as proxy} from './auth.js'
import {NextResponse} from 'next/server'
import {auth} from './auth.js'
import {createRateLimiter} from '@/lib/rate-limit.js'
// 5 inscriptions max par IP toutes les 15 minutes
const checkRegister = createRateLimiter({windowMs: 15 * 60 * 1000, max: 5})
// 10 tentatives de connexion max par IP toutes les 5 minutes
const checkSignin = createRateLimiter({windowMs: 5 * 60 * 1000, max: 10})
const limiters = {
'/api/auth/register': checkRegister,
'/api/auth/callback/credentials': checkSignin,
}
/**
* Extrait l'IP cliente depuis les headers HTTP.
* Priorité à X-Real-IP (Nginx), puis X-Forwarded-For.
*/
function getClientIp(request) {
const realIp = request.headers.get('x-real-ip')
if (realIp) {
return realIp.trim()
}
const forwarded = request.headers.get('x-forwarded-for')
if (forwarded) {
return forwarded.split(',')[0].trim()
}
return 'unknown'
}
export async function proxy(request) {
const {pathname} = request.nextUrl
const check = limiters[pathname]
// Rate limiting sur les routes d'authentification critiques
if (check) {
const ip = getClientIp(request)
const result = check(`${ip}:${pathname}`)
if (!result.success) {
return NextResponse.json(
{message: 'Trop de tentatives. Veuillez réessayer dans quelques minutes.'},
{
status: 429,
headers: {'Retry-After': String(result.retryAfter)},
}
)
}
}
return auth(request)
}
+24
View File
@@ -0,0 +1,24 @@
import * as Sentry from '@sentry/nextjs'
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Taux d'échantillonnage des traces de performance (0 = désactivé, 1 = 100%)
// Valeur basse en production pour limiter le volume
tracesSampleRate: 0.1,
// Capture des replays de session uniquement sur les erreurs
replaysOnErrorSampleRate: 1,
replaysSessionSampleRate: 0,
integrations: [
Sentry.replayIntegration({
// Masquer les champs sensibles dans les replays
maskAllText: false,
blockAllMedia: false,
}),
],
// Ne pas afficher les erreurs Sentry dans la console en production
debug: false,
})
+9
View File
@@ -0,0 +1,9 @@
import * as Sentry from '@sentry/nextjs'
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 0.1,
debug: false,
})
+10
View File
@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/nextjs'
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Taux d'échantillonnage des traces serveur
tracesSampleRate: 0.1,
debug: false,
})
+10 -10
View File
@@ -12,16 +12,16 @@
- [x] **Headers CSP**`next.config.mjs` (renommé depuis .js) avec CSP + 4 headers sécurité
- [x] **Tests unitaires** — Vitest sur `lib/format.js`, `lib/version-utils.js`, `lib/rate-limit.js`
- [ ] **Tests extensions Directus** — mocks VersionsService
- [ ] **Refresh token explicite** — callback `jwt` dans NextAuth options
- [ ] **Pipeline CI**GitHub Actions (lint + test + build)
- [ ] **Sentry** — tracking erreurs frontend + API routes
- [x] **Tests extensions Directus** — mocks VersionsService
- [x] **Refresh token explicite** — callback `jwt` dans NextAuth options
- [ ] **Pipeline CI**Forgejo Actions / Woodpecker CI (lint + test + build) ⏸ en attente
- [x] **Sentry** — tracking erreurs frontend + API routes
## Améliorations moyennes (P3)
- [ ] ISR page d'accueil (`revalidate`)
- [ ] Dockerisation frontend (`output: standalone`)
- [ ] Audit accessibilité WCAG 2.1
- [ ] Responsive mobile dashboard
- [ ] Lazy loading jsPDF + md-editor
- [ ] Migration NextAuth v5 stable
- [x] ISR page d'accueil (`revalidate`)
- [x] Dockerisation frontend (`output: standalone`)
- [x] Audit accessibilité WCAG 2.1
- [ ] Responsive mobile dashboard ⚠️ à vérifier manuellement (DevTools mobile / vrai appareil)
- [x] Lazy loading jsPDF + md-editor (déjà implémenté : dynamic import + next/dynamic)
- [ ] Migration NextAuth v5 stable ⏸ bloquée — v5 stable non publiée (beta.30 = dernière dispo)
+2516 -827
View File
File diff suppressed because it is too large Load Diff