Compare commits

36 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
cedric 7b831d5bc4 test: tests unitaires Vitest — format, version-utils, rate-limit
- Installe vitest@4 + @vitest/coverage-v8 (40 tests, 0 échec)
- lib/__tests__/format.test.js        : 14 tests (formatKonstitisyon, formatDate, hasRestrictedChar)
- lib/__tests__/version-utils.test.js : 17 tests (filterVersions par texte/auteur/date, getFilterStats)
- lib/__tests__/rate-limit.test.js    : 9 tests avec fake timers (limite, reset, retryAfter, keys indépendantes)
- vitest.config.mjs : environnement node, imports explicites (pas de globals)
- package.json : scripts test / test:watch / test:coverage + override XO pour les fichiers de test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 06:30:10 +04:00
cedric 170c3c5e90 security: Content Security Policy et headers HTTP sécurité
- Renomme next.config.js → next.config.mjs (ESM, satisfait unicorn/prefer-module)
- Ajout de headers() avec CSP stricte :
    script/style-src 'unsafe-inline' (requis Next.js + Emotion/MUI)
    connect-src dynamique depuis les env vars Directus (API + WebSocket)
    object-src 'none', frame-ancestors 'none', base-uri 'self'
    img-src 'self' data: blob: (html2canvas / export PDF)
- Ajout X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
  Permissions-Policy sur toutes les routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:55:40 +04:00
cedric dc1f115bd6 security: sanitiser la sortie marked avec DOMPurify (XSS)
export-pdf-button et print-button injectaient marked(content) directement
dans innerHTML / document.write. Un lien Markdown javascript: passait le
filtre hasRestrictedChar et pouvait s'exécuter.

Ajout de DOMPurify.sanitize() via import dynamique (déjà présent en dep
transitive de jspdf) sur les deux composants, avec whitelist de tags
et d'attributs stricte. markdown-renderer n'est pas touché car
react-markdown-preview utilise rehype-sanitize en interne.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:48:26 +04:00
cedric d8a63bc4d8 feat: rate limiting sur les routes d'authentification critiques
- Ajout de lib/rate-limit.js : fabrique de limiter en mémoire (closure +
  Map avec nettoyage lazy), sans dépendance externe, réutilisable
- Ajout de middleware.js : intercepte /api/auth/register (5 req/15min)
  et /api/auth/callback/credentials (10 req/5min), répond 429 + Retry-After
- Ajout de tasks/todo.md et tasks/lessons.md (suivi CLAUDE.md)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:30:38 +04:00
cedric 22130529f6 feat: récupère le total des votes 2026-01-24 23:35:48 +04:00
cedric b838f46b2b fix: change color & variant pour le total des votes 2026-01-24 22:25:53 +04:00
cedric c2f8a4fb19 feat: ajout du nombre de vote total 2026-01-24 22:14:49 +04:00
cedric a184665ed1 feat: simplifie la vue timeline 2026-01-24 21:34:02 +04:00
cedric be45cc1cc0 docs: ajout de proxy_buffer et X-Forwarded-Host dans la configuration nginx 2026-01-24 17:56:56 +04:00
cedric 5ee2e3707a build: upgrade next-auth 2026-01-24 13:42:20 +04:00
cedric 6f214f7468 feat: ajoute la possibilité de désactiver les websockets 2026-01-24 13:22:35 +04:00
cedric 8ec761b2c8 fix: ajout d'un cercle circulaire lors du chargement des commentaires 2026-01-24 12:23:04 +04:00
cedric 315c71baa4 feat: denier titre publié dans le select lors de la création d'article 2026-01-24 09:08:20 +04:00
cedric d19fbf990b feat: ajout de la timezone pour les exports 2026-01-24 00:40:38 +04:00
cedric 760ca0609d docs: ajout de la documentation pour le déploiement 2026-01-23 23:23:09 +04:00
cedric de81fbfe5c fix: change titres sorting 2026-01-22 11:38:30 +04:00
cedric 1cf621b752 chore: renomme domaine konstitisyon.la vers konstitisyon.nu 2026-01-10 23:41:57 +04:00
cedric e7c4343bfc build: upgrade react-md-editor, jspdf, marked & react-virtuoso 2026-01-05 21:07:43 +04:00
cedric 2701957af8 fix: ferme les votes sur les versions obsolètes dans PDF/Print 2026-01-04 13:14:09 +04:00
cedric e101f503d2 feat: améliorer la gestion de WebSocket Directus 2026-01-04 13:13:49 +04:00
cedric 47d58680b3 build: upgrade Mui & d'autres lib liées 2026-01-04 13:13:25 +04:00
cedric 5679d71b5b build: upgrade date-fns vers 4.1.0 2026-01-04 13:13:03 +04:00
cedric 6134755888 fix: ajout de await à searchParams 2026-01-04 13:12:49 +04:00
cedric 7c41beb992 fix: erreur explicite lors de l'échec de connexion 2026-01-04 13:12:10 +04:00
cedric 1cedf24a65 chore: ajout de next.config 2026-01-04 13:11:59 +04:00
cedric 5249dda717 build: upgrade directus/sdk 2026-01-04 13:11:34 +04:00
44 changed files with 4523 additions and 1586 deletions
+9
View File
@@ -0,0 +1,9 @@
Dockerfile
.dockerignore
node_modules
.next
.git
.env
yarn.lock
tasks/
*.md
+6
View File
@@ -16,3 +16,9 @@ NEXT_PUBLIC_DIRECTUS_API_WS_URL=$DIRECTUS_API_WS_URL
# COMMENTS
COMMENTS_PER_PAGE=5
# WEBSOCKET
NEXT_PUBLIC_DISABLE_WEBSOCKET=false
# SENTRY
NEXT_PUBLIC_SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx
+214
View File
@@ -0,0 +1,214 @@
# Deploiement Frontend Next.js
Guide de deploiement du frontend Next.js sur un serveur Ubuntu.
## Prerequis
- Ubuntu 20.04+ / Debian 11+
- Acces root ou sudo
- Node.js 20+
- Backend Directus deploye (ex: `api.exemple.com`)
- Nom de domaine configure (ex: `exemple.com`)
## 1. Installation des dependances
```bash
sudo apt update && sudo apt upgrade -y
# Node.js 22 LTS
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
# Yarn et PM2
sudo npm install -g yarn pm2
# Nginx et Certbot
sudo apt install -y nginx certbot python3-certbot-nginx
```
## 2. Configuration du projet
```bash
# Cloner le projet
git clone <URL_DU_REPO> frontend
cd frontend
# Configurer l'environnement
cp .env.sample .env
nano .env
```
Variables a modifier dans `.env`:
```env
DIRECTUS_API_URL=https://api.exemple.com
DIRECTUS_API_WS_URL=wss://api.exemple.com/websocket
NEXTAUTH_URL=https://exemple.com
NEXTAUTH_SECRET=<openssl rand -base64 32>
NEXT_PUBLIC_URL=https://exemple.com
NEXT_PUBLIC_DIRECTUS_API_URL=https://api.exemple.com
NEXT_PUBLIC_DIRECTUS_API_WS_URL=wss://api.exemple.com/websocket
```
## 3. Build de production
```bash
yarn install --frozen-lockfile
yarn build
```
## 4. Demarrage avec PM2
```bash
pm2 start yarn --name "frontend" -- start
pm2 status
# Demarrage automatique au boot
pm2 startup
pm2 save
```
## 5. Configuration Nginx
```bash
sudo nano /etc/nginx/sites-available/exemple.com
```
```nginx
server {
listen 80;
listen [::]:80;
server_name exemple.com;
client_max_body_size 10M;
access_log /var/log/nginx/exemple.com.access.log;
error_log /var/log/nginx/exemple.com.error.log;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_cache_bypass $http_upgrade;
# Buffer sizes pour les gros headers/cookies (JWT)
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
# Timeouts
proxy_connect_timeout 60;
proxy_send_timeout 60;
proxy_read_timeout 60;
}
location /_next/static {
proxy_pass http://127.0.0.1:3000;
proxy_cache_valid 60m;
add_header Cache-Control "public, immutable, max-age=31536000";
}
}
```
Activer le site:
```bash
sudo ln -s /etc/nginx/sites-available/exemple.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
## 6. Certificat SSL
Verifier le DNS:
```bash
dig +short exemple.com
curl -4 ifconfig.me
```
Obtenir le certificat:
```bash
sudo certbot --nginx -d exemple.com
```
## 7. Verification
Ouvrir `https://exemple.com` dans un navigateur.
## Commandes utiles
```bash
# Logs PM2
pm2 logs frontend
# Statut
pm2 status
# Redemarrer
pm2 restart frontend
# Mise a jour
git pull origin main
yarn install --frozen-lockfile
yarn build
pm2 restart frontend
```
## Troubleshooting
### L'application ne demarre pas
```bash
pm2 logs frontend --lines 50
ls -la .next/
yarn start
```
### Erreur 502
```bash
pm2 status
curl http://localhost:3000
sudo tail -20 /var/log/nginx/exemple.com.error.log
```
Si le curl local fonctionne mais pas via Nginx, verifier les buffer sizes dans la config Nginx (necessaires pour les gros cookies JWT):
```nginx
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
```
### Erreur de connexion API
```bash
curl https://api.exemple.com/server/health
cat .env | grep DIRECTUS
```
### Erreur SSL
```bash
dig +short exemple.com
sudo certbot certificates
sudo certbot renew --force-renewal
```
## Configuration CORS Backend
Verifier que le backend autorise le frontend dans son `.env`:
```env
CORS_ENABLED=true
CORS_ORIGIN=true
```
+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"]
+4 -4
View File
@@ -1,10 +1,10 @@
# konstitisyon.la
# konstitisyon.nu
Plateforme collaborative dédiée à la rédaction citoyenne d'une constitution. Ce projet vise à permettre aux citoyens de participer activement au processus de rédaction constitutionnelle à travers un système transparent et démocratique.
## Vision du projet
L'objectif de konstitisyon.la est de démocratiser le processus de rédaction constitutionnelle en :
L'objectif de konstitisyon.nu est de démocratiser le processus de rédaction constitutionnelle en :
- Permettant à chaque citoyen de proposer des modifications
- Facilitant le débat et la discussion autour des propositions
- Assurant la transparence du processus de rédaction
@@ -41,9 +41,9 @@ L'objectif de konstitisyon.la est de démocratiser le processus de rédaction co
### Structure du projet
```
konstitisyon.la/
konstitisyon.nu/
├── app/ # Routes et pages Next.js
├── components/
├── components/
│ ├── konstitisyon/ # Composants liés à la constitution
│ └── versions/ # Gestion des versions et votes
├── lib/ # Utilitaires et configurations
+51 -15
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: {},
@@ -22,10 +53,6 @@ export const options = {
const user = await res.json()
if (!res.ok && user) {
throw new Error('E-mail ou mot de passe incorrect')
}
if (res.ok && user) {
return user
}
@@ -40,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: ['*']})
)
)
@@ -59,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
@@ -73,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
}
}
+3 -1
View File
@@ -19,7 +19,9 @@ export default function LoginForm() {
})
if (response?.error) {
if (response.error === 'Configuration') {
if (response.error === 'CredentialsSignin') {
setError('E-mail ou mot de passe incorrect')
} else if (response.error === 'Configuration') {
setError('Une erreur sest produite, contactez ladministrateur !')
} else {
setError(response.error)
+11 -2
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')
}
@@ -34,7 +35,7 @@ async function getData() {
_eq: 'published'
}
},
sort: 'numero'
sort: 'date_created'
})
)
@@ -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
@@ -3,8 +3,8 @@ import {redirect} from 'next/navigation'
import ResetPasswordForm from './form.js'
export default async function ResetPasswordPage({searchParams}) {
console.log('searchParams', searchParams)
const {token} = searchParams
const params = await searchParams
const {token} = params
if (!token) {
redirect('/login')
+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}
@@ -22,7 +22,7 @@ export default function HandleCreate({
useEffect(() => {
if (listItems && listItems.length > 0) {
setSelectValue(listItems[0].id)
setSelectValue(listItems.at(-1).id)
}
}, [listItems])
@@ -142,6 +142,7 @@ export default function HandleCreate({
collection={collection}
listItems={listItems}
handleFormSubmit={handleFormSubmit}
selectValue={selectValue}
setSelectValue={setSelectValue}
title='Article'
dialogText='Écrivez votre article'
+3 -2
View File
@@ -4,7 +4,7 @@ import InputLabel from '@mui/material/InputLabel'
import FormControl from '@mui/material/FormControl'
import NativeSelect from '@mui/material/NativeSelect'
export default function ListItems({items, selectLabel, setSelectValue}) {
export default function ListItems({items, selectLabel, selectValue, setSelectValue}) {
const handleChange = event => {
setSelectValue(event.target.value)
}
@@ -16,7 +16,7 @@ export default function ListItems({items, selectLabel, setSelectValue}) {
{selectLabel}
</InputLabel>
<NativeSelect
defaultValue=''
value={selectValue}
inputProps={{
name: 'content',
id: 'titre',
@@ -35,5 +35,6 @@ export default function ListItems({items, selectLabel, setSelectValue}) {
ListItems.propTypes = {
items: PropTypes.array.isRequired,
selectLabel: PropTypes.string.isRequired,
selectValue: PropTypes.string.isRequired,
setSelectValue: PropTypes.func.isRequired
}
+3
View File
@@ -24,6 +24,7 @@ export default function FormHandler({
listItems,
handleFormSubmit,
countdownRef,
selectValue,
setSelectValue,
contenu,
collection
@@ -51,6 +52,7 @@ export default function FormHandler({
<ListItems
items={listItems}
selectLabel='Titre associé *'
selectValue={selectValue}
setSelectValue={setSelectValue}
/>
)}
@@ -94,6 +96,7 @@ FormHandler.propTypes = {
setError: PropTypes.func.isRequired,
setIsErrorAlertOpen: PropTypes.func.isRequired,
handleFormSubmit: PropTypes.func.isRequired,
selectValue: PropTypes.string,
setSelectValue: PropTypes.func.isRequired,
dialogText: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
+2 -2
View File
@@ -116,6 +116,6 @@ export default function Konstitisyon({session, titres, articles}) {
Konstitisyon.propTypes = {
session: PropTypes.object,
titres: PropTypes.object.isRequired,
articles: PropTypes.object.isRequired
titres: PropTypes.array.isRequired,
articles: PropTypes.array.isRequired
}
+56 -35
View File
@@ -10,6 +10,7 @@ import Typography from '@mui/material/Typography'
import Pagination from '@mui/material/Pagination'
import Divider from '@mui/material/Divider'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
import {readItems, withToken} from '@directus/sdk'
import SessionExpired from '../session/session-expired.js'
import {directusClient, handleUserStatus} from '@/lib/directus.js'
@@ -20,6 +21,7 @@ const commentsPerPage = process.env.NEXT_PUBLIC_COMMENTS_PER_PAGE || 2
export default function ListComments({session, selectedTitre, isOpen, setIsOpen, setError, setIsErrorAlertOpen}) {
const countdownRef = useRef()
const [comments, setComments] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [page, setPage] = useState(1)
const pageCount = Math.ceil(comments.length / commentsPerPage)
@@ -27,8 +29,15 @@ export default function ListComments({session, selectedTitre, isOpen, setIsOpen,
const startIndex = (page - 1) * commentsPerPage
const selectedComments = comments.slice(startIndex, startIndex + commentsPerPage)
useEffect(() => {
setComments([])
setPage(1)
}, [selectedTitre?.id])
useEffect(() => {
async function fetchComments() {
setIsLoading(true)
try {
await handleUserStatus(session.user.accessToken, session.user.userId)
@@ -54,6 +63,8 @@ export default function ListComments({session, selectedTitre, isOpen, setIsOpen,
setError(error?.errors[0]?.message)
setIsErrorAlertOpen(true)
}
} finally {
setIsLoading(false)
}
}
@@ -74,42 +85,52 @@ export default function ListComments({session, selectedTitre, isOpen, setIsOpen,
<>
<Dialog open={isOpen} onClose={handleClose}>
<DialogTitle>Commentaires</DialogTitle>
<List sx={{width: '100%', maxWidth: 360, bgcolor: 'background.paper'}}>
{selectedComments && selectedComments.length > 0 ? selectedComments.map(({id, date_created, contenu, user_created}) => (
<React.Fragment key={id}>
<ListItem alignItems='flex-start'>
<ListItemText
primary={
<Typography sx={{textDecoration: 'underline'}} component='span' variant='body2'>
@{user_created.split('-')[0]}
</Typography>
}
secondary={
<>
<Typography
sx={{display: 'inline'}}
component='span'
variant='body2'
color='text.primary'
>
{contenu}
</Typography>
<br />
{formatDate(date_created, 'PPPPpp')}
</>
}
/>
</ListItem>
{isLoading ? (
<Box sx={{display: 'flex', justifyContent: 'center', p: 4}}>
<CircularProgress />
</Box>
) : (
<>
<List sx={{width: '100%', maxWidth: 360, bgcolor: 'background.paper'}}>
{selectedComments && selectedComments.length > 0 ? selectedComments.map(({id, date_created, contenu, user_created}) => (
<React.Fragment key={id}>
<ListItem alignItems='flex-start'>
<ListItemText
primary={
<Typography sx={{textDecoration: 'underline'}} component='span' variant='body2'>
@{user_created.split('-')[0]}
</Typography>
}
secondary={
<>
<Typography
sx={{display: 'inline'}}
component='span'
variant='body2'
color='text.primary'
>
{contenu}
</Typography>
<br />
{formatDate(date_created, 'PPPPpp')}
</>
}
/>
</ListItem>
<Divider component='li' />
</React.Fragment>
)) : (
<Typography textAlign='center'>Aucun commentaire</Typography>
)}
</List>
<Box sx={{display: 'flex', justifyContent: 'center'}}>
<Pagination size='small' sx={{marginBlock: 3}} color='success' count={pageCount} page={page} onChange={handleChange} />
</Box>
<Divider component='li' />
</React.Fragment>
)) : (
<Typography textAlign='center' sx={{p: 2}}>Aucun commentaire</Typography>
)}
</List>
{pageCount > 1 && (
<Box sx={{display: 'flex', justifyContent: 'center'}}>
<Pagination size='small' sx={{marginBlock: 3}} color='success' count={pageCount} page={page} onChange={handleChange} />
</Box>
)}
</>
)}
</Dialog>
<SessionExpired ref={countdownRef} setError={setError} setIsErrorAlertOpen={setIsErrorAlertOpen} />
</>
+55 -40
View File
@@ -15,7 +15,8 @@ import PersonAddIcon from '@mui/icons-material/PersonAdd'
import {createDirectus, realtime, staticToken} from '@directus/sdk'
import ConfirmationAlert from './confirmation-alert.js'
const apiWsUrl = process.env.DIRECTUS_API_WS_URL || process.env.NEXT_PUBLIC_DIRECTUS_API_WS_URL
const apiUrl = process.env.DIRECTUS_API_URL || process.env.NEXT_PUBLIC_DIRECTUS_API_URL
const disableWebSocket = process.env.NEXT_PUBLIC_DISABLE_WEBSOCKET === 'true'
const LightTooltip = styled(({className, ...props}) => (
<Tooltip {...props} classes={{popper: className}} />
@@ -32,62 +33,76 @@ export default function Sign({session, navButton}) {
const router = useRouter()
const [isOpen, setIsOpen] = useState(false)
const directusClientWS = createDirectus(apiWsUrl)
.with(staticToken(session?.user?.accessToken))
.with(realtime())
const handleSignout = () => {
setIsOpen(false)
signOut()
}
async function subscribe() {
const {subscription} = await directusClientWS.subscribe('directus_versions', {
event: 'create',
query: {
fields: ['*'],
filter: {
collection: {
_eq: 'titres'
}
}
}
})
for await (const item of subscription) {
console.log('New version created:', item)
}
}
useEffect(() => {
let cleanup = () => {}
if (session) {
if (disableWebSocket) {
return () => cleanup()
}
if (session?.user?.accessToken) {
(async () => {
try {
await directusClientWS.connect()
console.log('Creating WebSocket client with token...')
directusClientWS.onWebSocket('open', () => {
console.log({event: 'onopen'})
subscribe()
// Create client with static token
const client = createDirectus(apiUrl)
.with(staticToken(session.user.accessToken))
.with(realtime())
console.log('Connecting to WebSocket...')
await client.connect()
client.onWebSocket('open', () => {
console.log({event: 'onopen', message: 'WebSocket connection opened'})
})
directusClientWS.onWebSocket('message', message => {
client.onWebSocket('message', message => {
console.log({event: 'onmessage', message})
// Once authenticated, subscribe
if (message.type === 'auth' && message.status === 'ok') {
console.log({event: 'onmessage', message})
console.log('WebSocket authenticated successfully!')
// Subscribe to version changes
const startSubscription = async () => {
const {subscription} = await client.subscribe('directus_versions', {
event: 'create',
query: {
fields: ['*'],
filter: {
collection: {
_eq: 'titres'
}
}
}
})
for await (const item of subscription) {
console.log('New version created:', item)
}
}
startSubscription().catch(error => console.error('Subscription error:', error))
}
})
directusClientWS.onWebSocket('close', () => {
console.log({event: 'onclose'})
client.onWebSocket('close', () => {
console.log({event: 'onclose', message: 'WebSocket connection closed'})
})
directusClientWS.onWebSocket('error', error => {
client.onWebSocket('error', error => {
console.log({event: 'onerror', error})
})
cleanup = () => {
directusClientWS.disconnect()
console.log('Disconnecting WebSocket...')
client.disconnect()
}
} catch (error) {
console.error('WebSocket connection error:', error)
@@ -96,7 +111,7 @@ export default function Sign({session, navButton}) {
}
return () => cleanup()
}, [session]) // eslint-disable-line react-hooks/exhaustive-deps
}, [session?.user?.accessToken])
return (
<>
@@ -104,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>
@@ -117,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>
+113 -47
View File
@@ -29,8 +29,11 @@ const renderMarkdownToHtml = async content => {
}
try {
// Dynamic import of markdown parser
const {marked} = await import('marked')
// Dynamic import of markdown parser and sanitizer
const [{marked}, {default: DOMPurify}] = await Promise.all([
import('marked'),
import('dompurify')
])
// Configure marked for better PDF rendering
marked.setOptions({
breaks: true, // Convert \n to <br>
@@ -39,14 +42,49 @@ const renderMarkdownToHtml = async content => {
mangle: false // Don't mangle email addresses
})
return marked(content)
return DOMPurify.sanitize(marked(content), {
ALLOWED_TAGS: [
'p',
'strong',
'em',
'b',
'i',
'ul',
'ol',
'li',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'blockquote',
'code',
'pre',
'br',
'hr',
'a',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
],
ALLOWED_ATTR: [
'href',
'target',
'rel',
],
ALLOW_DATA_ATTR: false,
})
} catch (error) {
console.warn('Failed to parse markdown, falling back to plain text:', error)
return content.replaceAll('\n', '<br>')
}
}
export default function ExportPdfButton({versionData, size = 'medium', variant = 'outlined'}) {
export default function ExportPdfButton({versionData, isOutdated = false, voteCounts = null, size = 'medium', variant = 'outlined'}) {
const [isExporting, setIsExporting] = useState(false)
const handleExportPdf = async () => {
@@ -86,55 +124,55 @@ export default function ExportPdfButton({versionData, size = 'medium', variant =
.pdf-content p { margin: 10px 0; }
.pdf-content strong, .pdf-content b { font-weight: bold; }
.pdf-content em, .pdf-content i { font-style: italic; }
.pdf-content ul, .pdf-content ol {
margin: 10px 0;
padding-left: 25px;
.pdf-content ul, .pdf-content ol {
margin: 10px 0;
padding-left: 25px;
}
.pdf-content li { margin: 5px 0; }
.pdf-content blockquote {
margin: 15px 0;
padding: 10px 20px;
border-left: 4px solid #1976d2;
background-color: #f5f5f5;
font-style: italic;
.pdf-content blockquote {
margin: 15px 0;
padding: 10px 20px;
border-left: 4px solid #1976d2;
background-color: #f5f5f5;
font-style: italic;
}
.pdf-content code {
background-color: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 13px;
.pdf-content code {
background-color: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.pdf-content pre {
background-color: #f5f5f5;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
margin: 15px 0;
.pdf-content pre {
background-color: #f5f5f5;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
margin: 15px 0;
}
.pdf-content pre code {
background: none;
padding: 0;
.pdf-content pre code {
background: none;
padding: 0;
}
.pdf-content a { color: #1976d2; text-decoration: underline; }
.pdf-content hr {
border: none;
border-top: 1px solid #ccc;
margin: 20px 0;
.pdf-content hr {
border: none;
border-top: 1px solid #ccc;
margin: 20px 0;
}
.pdf-content table {
border-collapse: collapse;
margin: 15px 0;
width: 100%;
.pdf-content table {
border-collapse: collapse;
margin: 15px 0;
width: 100%;
}
.pdf-content th, .pdf-content td {
border: 1px solid #ccc;
padding: 8px;
text-align: left;
.pdf-content th, .pdf-content td {
border: 1px solid #ccc;
padding: 8px;
text-align: left;
}
.pdf-content th {
background-color: #f5f5f5;
font-weight: bold;
.pdf-content th {
background-color: #f5f5f5;
font-weight: bold;
}
`
document.head.append(styleElement)
@@ -142,9 +180,15 @@ export default function ExportPdfButton({versionData, size = 'medium', variant =
const authorName = versionData.user_created?.split('-')[0] || 'Système'
const createdAt = new Date(versionData.date_created)
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
const voteStatus = createdAt < threeDaysAgo ? 'fermé' : 'ouvert'
const isExpired = createdAt < threeDaysAgo
const voteStatus = (isExpired || isOutdated) ? 'fermé' : 'ouvert'
const voteColor = voteStatus === 'ouvert' ? '#2e7d32' : '#d32f2f'
// Vote counts display
const voteTotal = voteCounts ? voteCounts.total : 0
const voteTotalColor = voteTotal > 0 ? '#2e7d32' : (voteTotal < 0 ? '#d32f2f' : '#666')
const voteTotalSign = voteTotal >= 0 ? '+' : ''
// Render markdown content to HTML
const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu)
@@ -158,12 +202,28 @@ export default function ExportPdfButton({versionData, size = 'medium', variant =
<strong>Auteur :</strong> @${authorName}
</p>
<p style="margin: 5px 0; color: #666; font-size: 14px;">
<strong>Date de création :</strong> ${formatDate(versionData.date_created, 'PPpp')}
<strong>Date de création :</strong> ${formatDate(versionData.date_created, 'PPpp', {withTimezone: true})}
</p>
<p style="margin: 5px 0; color: #666; font-size: 14px;">
<strong>Statut du vote :</strong>
<strong>Statut du vote :</strong>
<span style="color: ${voteColor}; font-weight: bold;">${voteStatus}</span>
</p>
${voteCounts ? `
<div style="margin-top: 15px; padding: 15px; background-color: #f5f5f5; border-radius: 8px;">
<p style="margin: 0 0 10px 0; font-size: 16px; font-weight: bold; color: #333;">
📊 Résultats des votes
</p>
<p style="margin: 5px 0; font-size: 14px;">
👍 Votes positifs : <strong style="color: #2e7d32;">${voteCounts.positive}</strong>
</p>
<p style="margin: 5px 0; font-size: 14px;">
👎 Votes négatifs : <strong style="color: #d32f2f;">${voteCounts.negative}</strong>
</p>
<p style="margin: 10px 0 0 0; font-size: 16px; font-weight: bold;">
🏆 Total : <span style="color: ${voteTotalColor};">${voteTotalSign}${voteTotal}</span>
</p>
</div>
` : ''}
</div>
</div>
@@ -177,7 +237,7 @@ export default function ExportPdfButton({versionData, size = 'medium', variant =
</div>
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #888; text-align: center;">
Exporté depuis Konstitisyon.la le ${formatDate(new Date(), 'PPpp')}
Exporté depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp', {withTimezone: true})}
</div>
`
@@ -251,6 +311,12 @@ export default function ExportPdfButton({versionData, size = 'medium', variant =
ExportPdfButton.propTypes = {
versionData: PropTypes.object.isRequired,
isOutdated: PropTypes.bool,
voteCounts: PropTypes.shape({
positive: PropTypes.number,
negative: PropTypes.number,
total: PropTypes.number
}),
size: PropTypes.oneOf(['small', 'medium', 'large']),
variant: PropTypes.oneOf(['text', 'outlined', 'contained'])
}
+1 -1
View File
@@ -2,7 +2,7 @@
import {useState, useRef, useEffect} from 'react'
import PropTypes from 'prop-types'
import Grid from '@mui/material/Grid2'
import Grid from '@mui/material/Grid'
import Typography from '@mui/material/Typography'
import HomeIcon from '@mui/icons-material/Home'
import Box from '@mui/material/Box'
+87 -9
View File
@@ -1,4 +1,4 @@
import {forwardRef, useRef, useState} from 'react'
import {forwardRef, useRef, useState, useEffect} from 'react'
import PropTypes from 'prop-types'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
@@ -24,7 +24,7 @@ import ShareButton from './share-button.js'
import ExportPdfButton from './export-pdf-button.js'
import PrintButton from './print-button.js'
import {formatDate} from '@/lib/format.js'
import {compareVersion} from '@/lib/directus.js'
import {compareVersion, getVoteCounts} from '@/lib/directus.js'
import {filterVersions, getFilterStats} from '@/lib/version-utils.js'
const columns = [
@@ -84,7 +84,9 @@ function rowContent({
setError,
setIsErrorAlertOpen,
setIsOpenComparison,
setVersionCompare
setVersionCompare,
outdatedStatusMap,
voteCountsMap
}) {
const handleButtonClick = async versionId => {
const version = await compareVersion({
@@ -102,6 +104,9 @@ function rowContent({
}
}
const isOutdated = outdatedStatusMap[row.id] || false
const voteCounts = voteCountsMap[row.id] || null
return (
<>
{columns.map(column => (
@@ -137,11 +142,15 @@ function rowContent({
/>
<ExportPdfButton
versionData={row}
isOutdated={isOutdated}
voteCounts={voteCounts}
size='small'
variant='text'
/>
<PrintButton
versionData={row}
isOutdated={isOutdated}
voteCounts={voteCounts}
size='small'
variant='text'
/>
@@ -182,6 +191,53 @@ export default function ListVersions({
dateTo: '',
status: ''
})
const [outdatedStatusMap, setOutdatedStatusMap] = useState({})
const [voteCountsMap, setVoteCountsMap] = useState({})
// Fetch outdated status and vote counts for all versions
useEffect(() => {
async function fetchVersionsData() {
const statusMap = {}
const countsMap = {}
await Promise.all(
data.map(async version => {
try {
const comparisonData = await compareVersion({
accessToken,
userId,
versionId: version.id,
countdownRef,
setError,
setIsErrorAlertOpen
})
if (comparisonData) {
statusMap[version.id] = comparisonData.outdated || false
}
// Fetch vote counts
const counts = await getVoteCounts({
accessToken,
versionId: version.id
})
countsMap[version.id] = counts
} catch (error) {
console.warn(`Failed to fetch data for version ${version.id}:`, error)
statusMap[version.id] = false
countsMap[version.id] = {positive: 0, negative: 0, total: 0}
}
})
)
setOutdatedStatusMap(statusMap)
setVoteCountsMap(countsMap)
}
if (data.length > 0) {
fetchVersionsData()
}
}, [data, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen])
// Filter data based on search and filters
const filteredData = filterVersions(data, searchTerm, filters)
@@ -189,6 +245,19 @@ export default function ListVersions({
const versionData = data.find(({id}) => id === versionCompare?.versionId)
// Function to refresh vote counts for a specific version after voting
const refreshVoteCounts = async versionId => {
try {
const counts = await getVoteCounts({
accessToken,
versionId
})
setVoteCountsMap(prev => ({...prev, [versionId]: counts}))
} catch (error) {
console.warn(`Failed to refresh vote counts for version ${versionId}:`, error)
}
}
const handleSearchChange = newSearchTerm => {
setSearchTerm(newSearchTerm)
}
@@ -208,8 +277,10 @@ export default function ListVersions({
<Box>
<Box sx={{
display: 'flex',
flexDirection: {xs: 'column', sm: 'row'},
justifyContent: 'space-between',
alignItems: 'center',
alignItems: {xs: 'stretch', sm: 'center'},
gap: 1,
mb: 2
}}
>
@@ -230,13 +301,14 @@ export default function ListVersions({
size='small'
value={viewMode}
onChange={handleViewModeChange}
sx={{alignSelf: {xs: 'center', sm: 'auto'}}}
>
<ToggleButton value='table' aria-label='vue tableau'>
<ViewListIcon sx={{mr: 1}} />
<ViewListIcon fontSize='small' sx={{mr: 0.5}} />
Table
</ToggleButton>
<ToggleButton value='timeline' aria-label='vue chronologique'>
<TimelineIcon sx={{mr: 1}} />
<TimelineIcon fontSize='small' sx={{mr: 0.5}} />
Timeline
</ToggleButton>
</ToggleButtonGroup>
@@ -261,25 +333,31 @@ export default function ListVersions({
components={VirtuosoTableComponents}
fixedHeaderContent={fixedHeaderContent}
itemContent={(index, row) => rowContent({
index, row, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen, setIsOpenComparison, setVersionCompare
index, row, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen, setIsOpenComparison, setVersionCompare, outdatedStatusMap, voteCountsMap
})}
/>
</Paper>
) : (
<VersionTimeline
collection={collection}
data={filteredData}
accessToken={accessToken}
userId={userId}
setError={setError}
setIsErrorAlertOpen={setIsErrorAlertOpen}
onVoteSuccess={refreshVoteCounts}
/>
)
)}
</Box>
{isOpenComparison && (
<VersionDialog versionData={versionData} versionCompare={versionCompare} isOpen={isOpenComparison} setIsOpen={setIsOpenComparison} />
<VersionDialog
versionData={versionData}
versionCompare={versionCompare}
isOpen={isOpenComparison}
setIsOpen={setIsOpenComparison}
onVoteSuccess={refreshVoteCounts}
/>
)}
<SessionExpired ref={countdownRef} setError={setError} setIsErrorAlertOpen={setIsErrorAlertOpen} />
</>
+75 -9
View File
@@ -31,8 +31,11 @@ const renderMarkdownToHtml = async content => {
}
try {
// Dynamic import of markdown parser
const {marked} = await import('marked')
// Dynamic import of markdown parser and sanitizer
const [{marked}, {default: DOMPurify}] = await Promise.all([
import('marked'),
import('dompurify')
])
// Configure marked for better print rendering
marked.setOptions({
@@ -42,14 +45,49 @@ const renderMarkdownToHtml = async content => {
mangle: false // Don't mangle email addresses
})
return marked(content)
return DOMPurify.sanitize(marked(content), {
ALLOWED_TAGS: [
'p',
'strong',
'em',
'b',
'i',
'ul',
'ol',
'li',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'blockquote',
'code',
'pre',
'br',
'hr',
'a',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
],
ALLOWED_ATTR: [
'href',
'target',
'rel',
],
ALLOW_DATA_ATTR: false,
})
} catch (error) {
console.warn('Failed to parse markdown, falling back to plain text:', error)
return content.replaceAll('\n', '<br>')
}
}
export default function PrintButton({versionData, size = 'medium', variant = 'outlined'}) {
export default function PrintButton({versionData, isOutdated = false, voteCounts = null, size = 'medium', variant = 'outlined'}) {
const [isPrinting, setIsPrinting] = useState(false)
const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'})
@@ -61,9 +99,15 @@ export default function PrintButton({versionData, size = 'medium', variant = 'ou
const authorName = versionData.user_created?.split('-')[0] || 'Système'
const createdAt = new Date(versionData.date_created)
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
const voteStatus = createdAt < threeDaysAgo ? 'fermé' : 'ouvert'
const isExpired = createdAt < threeDaysAgo
const voteStatus = (isExpired || isOutdated) ? 'fermé' : 'ouvert'
const voteColor = voteStatus === 'ouvert' ? '#2e7d32' : '#d32f2f'
// Vote counts display
const voteTotal = voteCounts ? voteCounts.total : 0
const voteTotalColor = voteTotal > 0 ? '#2e7d32' : (voteTotal < 0 ? '#d32f2f' : '#666')
const voteTotalSign = voteTotal >= 0 ? '+' : ''
// Render markdown content to HTML
const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu)
@@ -81,7 +125,7 @@ export default function PrintButton({versionData, size = 'medium', variant = 'ou
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${versionData.name} - Konstitisyon.la</title>
<title>${versionData.name} - Konstitisyon.nu</title>
<style>
/* Print-optimized styles */
@media print {
@@ -302,12 +346,28 @@ export default function PrintButton({versionData, size = 'medium', variant = 'ou
<strong>Auteur :</strong> @${authorName}
</div>
<div class="metadata">
<strong>Date de création :</strong> ${formatDate(versionData.date_created, 'PPpp')}
<strong>Date de création :</strong> ${formatDate(versionData.date_created, 'PPpp', {withTimezone: true})}
</div>
<div class="metadata">
<strong>Statut du vote :</strong>
<strong>Statut du vote :</strong>
<span class="vote-status">${voteStatus}</span>
</div>
${voteCounts ? `
<div style="margin-top: 15px; padding: 15px; background-color: #f8f9fa; border-radius: 8px; border: 1px solid #e0e0e0;">
<p style="margin: 0 0 10px 0; font-size: 16px; font-weight: bold; color: #333;">
📊 Résultats des votes
</p>
<p style="margin: 5px 0; font-size: 14px;">
👍 Votes positifs : <strong style="color: #2e7d32;">${voteCounts.positive}</strong>
</p>
<p style="margin: 5px 0; font-size: 14px;">
👎 Votes négatifs : <strong style="color: #d32f2f;">${voteCounts.negative}</strong>
</p>
<p style="margin: 10px 0 0 0; font-size: 16px; font-weight: bold;">
🏆 Total : <span style="color: ${voteTotalColor};">${voteTotalSign}${voteTotal}</span>
</p>
</div>
` : ''}
</div>
<div class="content-section">
@@ -318,7 +378,7 @@ export default function PrintButton({versionData, size = 'medium', variant = 'ou
</div>
<div class="footer">
Imprimé depuis Konstitisyon.la le ${formatDate(new Date(), 'PPpp')}
Imprimé depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp', {withTimezone: true})}
</div>
</body>
</html>
@@ -390,6 +450,12 @@ export default function PrintButton({versionData, size = 'medium', variant = 'ou
PrintButton.propTypes = {
versionData: PropTypes.object.isRequired,
isOutdated: PropTypes.bool,
voteCounts: PropTypes.shape({
positive: PropTypes.number,
negative: PropTypes.number,
total: PropTypes.number
}),
size: PropTypes.oneOf(['small', 'medium', 'large']),
variant: PropTypes.oneOf(['text', 'outlined', 'contained'])
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function ShareButton({
// Use native share API if available
await navigator.share({
title: `Version: ${versionName}`,
text: 'Découvrez cette version sur Konstitisyon.la',
text: 'Découvrez cette version sur Konstitisyon.nu',
url
})
} else if (navigator.clipboard && window.isSecureContext) {
+80 -24
View File
@@ -1,21 +1,55 @@
import Box from '@mui/material/Box'
import Typography from '@mui/material/Typography'
import Paper from '@mui/material/Paper'
import Grid from '@mui/material/Grid2'
import Grid from '@mui/material/Grid'
import Chip from '@mui/material/Chip'
import PropTypes from 'prop-types'
import Snackbar from '@mui/material/Snackbar'
import Alert from '@mui/material/Alert'
import {useState} from 'react'
import ThumbUpIcon from '@mui/icons-material/ThumbUp'
import ThumbDownIcon from '@mui/icons-material/ThumbDown'
import {useState, useEffect} from 'react'
import {useSession} from 'next-auth/react'
import MarkdownRenderer from '../markdown-renderer/index.js'
import VoteButtons from './vote-buttons.js'
import CopyButton from './copy-button.js'
import {formatDate} from '@/lib/format.js'
import {getVoteCounts} from '@/lib/directus.js'
export default function VersionComparison({versionData, versionCompare, voteRefreshKey = 0, onVoteResult}) {
export default function VersionComparison({versionData, versionCompare, voteRefreshKey = 0, onVoteResult, onVoteSuccess}) {
const {data: session} = useSession()
const {current, main, outdated} = versionCompare
const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'})
const [voteCounts, setVoteCounts] = useState({positive: 0, negative: 0, total: 0})
useEffect(() => {
async function fetchVoteCounts() {
if (session?.user?.accessToken && versionCompare?.versionId) {
const counts = await getVoteCounts({
accessToken: session.user.accessToken,
versionId: versionCompare.versionId
})
setVoteCounts(counts)
}
}
fetchVoteCounts()
}, [session?.user?.accessToken, versionCompare?.versionId, voteRefreshKey])
const handleVoteResult = async result => {
if (result.success && session?.user?.accessToken && versionCompare?.versionId) {
const counts = await getVoteCounts({
accessToken: session.user.accessToken,
versionId: versionCompare.versionId
})
setVoteCounts(counts)
if (onVoteSuccess) {
onVoteSuccess(versionCompare.versionId)
}
}
const handleVoteResult = result => {
if (onVoteResult) {
// Use the parent's vote result handler if provided
onVoteResult(result)
@@ -35,7 +69,8 @@ export default function VersionComparison({versionData, versionCompare, voteRefr
const createdAt = new Date(versionData.date_created)
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
const isVoteDisabled = createdAt < threeDaysAgo
const isExpired = createdAt < threeDaysAgo
const isVoteDisabled = isExpired || outdated
return (
<Box sx={{padding: 3}}>
@@ -106,31 +141,51 @@ export default function VersionComparison({versionData, versionCompare, voteRefr
@{versionData.user_created?.split('-')[0] || 'Système'}
</Typography>
</Box>
{!outdated && (
<Box sx={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1
}}
>
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
{versionData && (
<Typography sx={{fontWeight: 'bold'}} color={isVoteDisabled ? 'error' : 'primary'}>
{formatDate(versionData.date_created)}
</Typography>
)}
<CopyButton
content={current.contenu || ''}
label='Copier cette version'
hasSnackbarVisible={false}
/>
</Box>
<Box sx={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1, flexWrap: 'wrap', gap: 1
}}
>
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
{versionData && (
<Typography sx={{fontWeight: 'bold'}} color={isVoteDisabled ? 'error' : 'primary'}>
{formatDate(versionData.date_created)}
</Typography>
)}
<CopyButton
content={current.contenu || ''}
label='Copier cette version'
hasSnackbarVisible={false}
/>
</Box>
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
<VoteButtons
key={`vote-comparison-${voteRefreshKey}`}
versionId={versionCompare.versionId}
isDisabled={isVoteDisabled}
onVoteResult={handleVoteResult}
/>
<Chip
icon={<ThumbUpIcon />}
label={voteCounts.positive}
size='small'
color='success'
variant='outlined'
/>
<Chip
icon={<ThumbDownIcon />}
label={voteCounts.negative}
size='small'
color='error'
variant='outlined'
/>
<Chip
label={`Total: ${voteCounts.total >= 0 ? '+' : ''}${voteCounts.total}`}
size='small'
color={voteCounts.total > 0 ? 'success' : (voteCounts.total < 0 ? 'error' : 'primary')}
variant='outlined'
/>
</Box>
)}
</Box>
</Paper>
</Grid>
</Grid>
@@ -214,5 +269,6 @@ VersionComparison.propTypes = {
versionId: PropTypes.string
}).isRequired,
voteRefreshKey: PropTypes.number,
onVoteResult: PropTypes.func
onVoteResult: PropTypes.func,
onVoteSuccess: PropTypes.func
}
+4 -3
View File
@@ -14,7 +14,7 @@ import CompareArrowsIcon from '@mui/icons-material/CompareArrows'
import {useTheme} from '@mui/material/styles'
import VersionComparison from './version-comparison.js'
export default function VersionDialog({versionData, versionCompare, isOpen, setIsOpen}) {
export default function VersionDialog({versionData, versionCompare, isOpen, setIsOpen, onVoteSuccess}) {
const theme = useTheme()
const fullScreen = useMediaQuery(theme.breakpoints.down('md'))
@@ -59,7 +59,7 @@ export default function VersionDialog({versionData, versionCompare, isOpen, setI
</DialogTitle>
<DialogContent sx={{minHeight: '60vh'}}>
<VersionComparison versionData={versionData} versionCompare={versionCompare} />
<VersionComparison versionData={versionData} versionCompare={versionCompare} onVoteSuccess={onVoteSuccess} />
</DialogContent>
<DialogActions sx={{px: 3, py: 2}}>
@@ -84,5 +84,6 @@ VersionDialog.propTypes = {
main: PropTypes.object.isRequired
}).isRequired,
isOpen: PropTypes.bool.isRequired,
setIsOpen: PropTypes.func.isRequired
setIsOpen: PropTypes.func.isRequired,
onVoteSuccess: PropTypes.func
}
+33 -19
View File
@@ -24,7 +24,7 @@ import CopyButton from './copy-button.js'
import ExportPdfButton from './export-pdf-button.js'
import PrintButton from './print-button.js'
import VersionComparison from './version-comparison.js'
import {getVersion, compareVersion} from '@/lib/directus.js'
import {getVersion, compareVersion, getVoteCounts} from '@/lib/directus.js'
import {formatDate} from '@/lib/format.js'
export default function VersionPage({session, versionId, viewMode}) {
@@ -39,6 +39,7 @@ export default function VersionPage({session, versionId, viewMode}) {
const [isErrorAlertOpen, setIsErrorAlertOpen] = useState(false)
const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'})
const [voteRefreshKey, setVoteRefreshKey] = useState(0)
const [voteCounts, setVoteCounts] = useState(null)
useEffect(() => {
async function fetchVersionData() {
@@ -54,21 +55,26 @@ export default function VersionPage({session, versionId, viewMode}) {
setVersionData(version)
// If in comparison mode, also fetch comparison data
if (viewMode === 'comparison') {
const comparison = await compareVersion({
accessToken,
userId,
versionId,
countdownRef,
setError,
setIsErrorAlertOpen
})
// Fetch comparison data (needed for outdated status even if not in comparison mode)
const comparison = await compareVersion({
accessToken,
userId,
versionId,
countdownRef,
setError,
setIsErrorAlertOpen
})
if (comparison) {
setVersionCompare({...comparison, versionId})
}
if (comparison) {
setVersionCompare({...comparison, versionId})
}
const counts = await getVoteCounts({
accessToken,
versionId
})
setVoteCounts(counts)
} catch (error) {
console.error('Failed to fetch version:', error)
setError('Impossible de charger cette version')
@@ -79,13 +85,13 @@ export default function VersionPage({session, versionId, viewMode}) {
}
fetchVersionData()
}, [accessToken, userId, versionId, viewMode])
}, [accessToken, userId, versionId])
const handleBack = () => {
router.push('/dashboard')
}
const handleVoteResult = result => {
const handleVoteResult = async result => {
setSnackbar({
open: true,
message: result.message,
@@ -93,6 +99,14 @@ export default function VersionPage({session, versionId, viewMode}) {
})
// Force refresh of both VoteButtons components by changing the key
setVoteRefreshKey(prev => prev + 1)
if (result.success) {
const counts = await getVoteCounts({
accessToken,
versionId
})
setVoteCounts(counts)
}
}
const handleCloseSnackbar = () => {
@@ -107,7 +121,7 @@ export default function VersionPage({session, versionId, viewMode}) {
// Use native share API if available
await navigator.share({
title: `Version: ${versionData?.name || 'Version'}`,
text: 'Découvrez cette version sur Konstitisyon.la',
text: 'Découvrez cette version sur Konstitisyon.nu',
url
})
setSnackbar({
@@ -224,8 +238,8 @@ export default function VersionPage({session, versionId, viewMode}) {
</Button>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<ExportPdfButton versionData={versionData} size='medium' />
<PrintButton versionData={versionData} size='medium' />
<ExportPdfButton versionData={versionData} isOutdated={versionCompare?.outdated} voteCounts={voteCounts} size='medium' />
<PrintButton versionData={versionData} isOutdated={versionCompare?.outdated} voteCounts={voteCounts} size='medium' />
<Tooltip title='Partager cette version'>
<IconButton color='primary' onClick={handleShare}>
<ShareIcon />
+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'>
+200 -284
View File
@@ -1,110 +1,36 @@
import {useRef, useState} from 'react'
import {useTheme} from '@mui/material/styles'
import {useRef, useState, useEffect} from 'react'
import PropTypes from 'prop-types'
import Box from '@mui/material/Box'
import Typography from '@mui/material/Typography'
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import CardActions from '@mui/material/CardActions'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import Avatar from '@mui/material/Avatar'
import Divider from '@mui/material/Divider'
import Timeline from '@mui/lab/Timeline'
import TimelineItem from '@mui/lab/TimelineItem'
import TimelineSeparator from '@mui/lab/TimelineSeparator'
import TimelineConnector from '@mui/lab/TimelineConnector'
import TimelineContent from '@mui/lab/TimelineContent'
import TimelineDot from '@mui/lab/TimelineDot'
import TimelineOppositeContent from '@mui/lab/TimelineOppositeContent'
import AccessTimeIcon from '@mui/icons-material/AccessTime'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import ErrorIcon from '@mui/icons-material/Error'
import EditIcon from '@mui/icons-material/Edit'
import IconButton from '@mui/material/IconButton'
import Collapse from '@mui/material/Collapse'
import Snackbar from '@mui/material/Snackbar'
import Alert from '@mui/material/Alert'
import CompareArrowsIcon from '@mui/icons-material/CompareArrows'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import SessionExpired from '../session/session-expired.js'
import MarkdownRenderer from '../markdown-renderer/index.js'
import VersionDialog from './version-dialog.js'
import VoteButtons from './vote-buttons.js'
import CopyButton from './copy-button.js'
import ShareButton from './share-button.js'
import ExportPdfButton from './export-pdf-button.js'
import PrintButton from './print-button.js'
import {formatDate} from '@/lib/format.js'
import {compareVersion} from '@/lib/directus.js'
function getVersionStatus(version, index, totalVersions) {
// Logic to determine version status based on position and data
function getStatusColor(isOutdated, index) {
if (isOutdated) {
return '#D32F2F'
}
if (index === 0) {
return 'current' // Most recent
return '#1976D2'
}
if (index === totalVersions - 1) {
return 'initial' // First version
}
return 'archived' // Intermediate versions
return '#9E9E9E'
}
function getStatusConfig(status) {
switch (status) {
case 'current': {
return {
color: '#1976D2',
bgColor: '#E3F2FD',
icon: <EditIcon />,
label: 'En cours',
chipColor: 'primary'
}
}
case 'published': {
return {
color: '#2E7D32',
bgColor: '#E8F5E9',
icon: <CheckCircleIcon />,
label: 'Publié',
chipColor: 'success'
}
}
case 'archived': {
return {
color: '#757575',
bgColor: '#F5F5F5',
icon: <AccessTimeIcon />,
label: 'Archivé',
chipColor: 'default'
}
}
case 'outdated': {
return {
color: '#D32F2F',
bgColor: '#F9E8E8',
icon: <ErrorIcon />,
label: 'Obsolète',
chipColor: 'error'
}
}
default: {
return {
color: '#757575',
bgColor: '#F5F5F5',
icon: <AccessTimeIcon />,
label: 'Archivé',
chipColor: 'default'
}
}
}
}
function VersionCard({
function VersionItem({
version,
index,
totalVersions,
accessToken,
userId,
countdownRef,
@@ -114,18 +40,39 @@ function VersionCard({
setVersionCompare,
onVoteResult
}) {
const theme = useTheme()
const status = getVersionStatus(version, index, totalVersions)
const [isOutdated, setIsOutdated] = useState(false)
const [expanded, setExpanded] = useState(false)
const statusConfig = getStatusConfig(status)
const userDisplayName = version.user_created?.split('-')[0] || 'Système'
useEffect(() => {
async function fetchStatus() {
try {
const comparisonData = await compareVersion({
accessToken,
userId,
versionId: version.id,
countdownRef,
setError,
setIsErrorAlertOpen
})
if (comparisonData) {
setIsOutdated(comparisonData.outdated)
}
} catch {
setIsOutdated(false)
}
}
fetchStatus()
}, [version.id, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen])
const statusColor = getStatusColor(isOutdated, index)
// Check if voting is disabled (after 3 days)
const createdAt = new Date(version.date_created)
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
const isVoteDisabled = createdAt < threeDaysAgo
const isVoteDisabled = createdAt < threeDaysAgo || isOutdated
const handleCompareClick = async () => {
const handleCompare = async () => {
const comparisonData = await compareVersion({
accessToken,
userId,
@@ -141,165 +88,160 @@ function VersionCard({
}
}
// Create content preview preserving markdown structure
const createContentPreview = content => {
if (!content) {
return 'Contenu non disponible'
}
// If content is short enough, return as is
if (content.length <= 150) {
return content
}
// Find a good breaking point (end of sentence, paragraph, or word)
const preview = content.slice(0, 150)
const lastSentence = Math.max(preview.lastIndexOf('.'), preview.lastIndexOf('!'), preview.lastIndexOf('?'))
const lastParagraph = preview.lastIndexOf('\n\n')
const lastWord = preview.lastIndexOf(' ')
// Choose the best breaking point
let breakPoint = 150
if (lastSentence > 100) {
breakPoint = lastSentence + 1
} else if (lastParagraph > 80) {
breakPoint = lastParagraph
} else if (lastWord > 100) {
breakPoint = lastWord
}
return content.slice(0, breakPoint) + (content.length > breakPoint ? '...' : '')
}
const contentPreview = createContentPreview(version?.delta?.contenu)
return (
<Card
<Box
sx={{
borderLeft: `4px solid ${statusConfig.color}`,
backgroundColor: statusConfig.bgColor,
mb: 2,
transition: 'all 0.2s ease-in-out',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: 3
}
display: 'flex',
gap: 1.5,
py: 1.5,
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-child': {borderBottom: 'none'}
}}
>
<CardContent>
<Box sx={{
{/* Status indicator */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
mb: 2
flexDirection: 'column',
alignItems: 'center',
pt: 0.5
}}
>
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: statusColor,
flexShrink: 0
}}
/>
<Box
sx={{
width: 2,
flex: 1,
bgcolor: 'divider',
mt: 0.5,
display: index === 0 ? 'none' : 'block'
}}
/>
</Box>
{/* Content */}
<Box sx={{flex: 1, minWidth: 0}}>
{/* Header row */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1
}}
>
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
<Avatar sx={{bgcolor: statusConfig.color, width: 32, height: 32}}>
{statusConfig.icon}
</Avatar>
<Box>
<Typography variant='h6' sx={{fontWeight: 'bold', color: statusConfig.color}}>
{version.name}
</Typography>
<Typography variant='caption' color='text.secondary'>
par @{userDisplayName}
</Typography>
</Box>
<Box sx={{minWidth: 0, flex: 1}}>
<Typography
variant='body2'
sx={{
fontWeight: 600,
color: statusColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{version.name}
</Typography>
<Typography variant='caption' color='text.secondary'>
{formatDate(version.date_created, 'dd/MM/yy HH:mm')}
</Typography>
</Box>
<Box sx={{display: 'flex', gap: 1}}>
<Chip
label={statusConfig.label}
color={statusConfig.chipColor}
size='small'
variant='outlined'
/>
{isVoteDisabled && (
<Chip
label='Vote fermé'
color='error'
size='small'
variant='outlined'
/>
)}
</Box>
</Box>
<Box sx={{mb: 2, '& > div': {fontSize: '0.875rem', fontStyle: 'italic'}}}>
<MarkdownRenderer
content={contentPreview}
color={theme.palette.text.secondary}
fallbackComponent={({children, ...props}) => (
<Typography
variant='body2'
color='text.secondary'
sx={{fontStyle: 'italic'}}
{...props}
>
{children}
</Typography>
)}
/>
</Box>
<Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}>
<Typography variant='caption' color='text.secondary'>
{formatDate(version.date_created, 'PPpp')}
</Typography>
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
<CopyButton
content={version.delta?.contenu || version.name || ''}
label='Copier le contenu de cette version'
hasSnackbarVisible={false}
/>
<ShareButton
versionId={version.id}
versionName={version.name}
hasSnackbarVisible={false}
/>
<ExportPdfButton
versionData={version}
size='small'
variant='text'
/>
<PrintButton
versionData={version}
size='small'
variant='text'
/>
{/* Actions */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
flexShrink: 0
}}
>
<VoteButtons
hasCountsVisible
versionId={version.id}
isDisabled={isVoteDisabled}
onVoteResult={onVoteResult}
/>
<IconButton size='small' aria-label='Comparer les versions' title='Comparer' onClick={handleCompare}>
<CompareArrowsIcon fontSize='small' />
</IconButton>
<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>
</Box>
</CardContent>
<Divider />
<CardActions sx={{justifyContent: 'flex-end'}}>
<Button
size='small'
variant='outlined'
color='primary'
onClick={handleCompareClick}
>
Comparer
</Button>
</CardActions>
</Card>
{/* Expanded content */}
<Collapse in={expanded}>
<Box
sx={{
mt: 1.5,
display: 'flex',
flexDirection: 'column',
gap: 1
}}
>
{/* Preview */}
{version.delta?.contenu && (
<Typography
variant='caption'
color='text.secondary'
sx={{
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
fontStyle: 'italic'
}}
>
{version.delta.contenu}
</Typography>
)}
{/* Actions row */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap'
}}
>
<CopyButton
content={version.delta?.contenu || version.name || ''}
label='Copier'
hasSnackbarVisible={false}
/>
</Box>
</Box>
</Collapse>
</Box>
</Box>
)
}
export default function VersionTimeline({
collection,
data,
accessToken,
userId,
setError,
setIsErrorAlertOpen
setIsErrorAlertOpen,
onVoteSuccess
}) {
const countdownRef = useRef()
const [isOpenComparison, setIsOpenComparison] = useState(false)
@@ -308,66 +250,36 @@ export default function VersionTimeline({
const versionData = data.find(({id}) => id === versionCompare?.versionId)
const handleVoteResult = result => {
const handleVoteResult = (result, versionId) => {
setSnackbar({
open: true,
message: result.message,
severity: result.success ? 'success' : 'error'
})
}
const handleCloseSnackbar = () => {
setSnackbar(prev => ({...prev, open: false}))
if (result.success && onVoteSuccess && versionId) {
onVoteSuccess(versionId)
}
}
return (
<>
<Box>
<Typography variant='h5' textAlign='center' sx={{mb: 3, fontWeight: 'bold'}}>
Historique des versions - {collection}
</Typography>
<Timeline position='right'>
{data.map((version, index) => (
<TimelineItem key={version.id}>
<TimelineOppositeContent sx={{flex: 0.3, pr: 2}}>
<Typography variant='caption' color='text.secondary'>
{formatDate(version.date_created, 'dd/MM/yyyy')}
</Typography>
<br />
<Typography variant='caption' color='text.secondary'>
{formatDate(version.date_created, 'HH:mm')}
</Typography>
</TimelineOppositeContent>
<TimelineSeparator>
<TimelineDot
color={index === 0 ? 'primary' : 'grey'}
variant={index === 0 ? 'filled' : 'outlined'}
>
{index === 0 ? <EditIcon /> : <AccessTimeIcon />}
</TimelineDot>
{index < data.length - 1 && <TimelineConnector />}
</TimelineSeparator>
<TimelineContent sx={{flex: 1}}>
<VersionCard
version={version}
index={index}
totalVersions={data.length}
accessToken={accessToken}
userId={userId}
countdownRef={countdownRef}
setError={setError}
setIsErrorAlertOpen={setIsErrorAlertOpen}
setIsOpenComparison={setIsOpenComparison}
setVersionCompare={setVersionCompare}
onVoteResult={handleVoteResult}
/>
</TimelineContent>
</TimelineItem>
))}
</Timeline>
<Box sx={{maxWidth: 500, mx: 'auto'}}>
{data.map((version, index) => (
<VersionItem
key={version.id}
version={version}
index={index}
accessToken={accessToken}
userId={userId}
countdownRef={countdownRef}
setError={setError}
setIsErrorAlertOpen={setIsErrorAlertOpen}
setIsOpenComparison={setIsOpenComparison}
setVersionCompare={setVersionCompare}
onVoteResult={result => handleVoteResult(result, version.id)}
/>
))}
</Box>
{isOpenComparison && (
@@ -389,9 +301,14 @@ export default function VersionTimeline({
open={snackbar.open}
autoHideDuration={6000}
anchorOrigin={{vertical: 'bottom', horizontal: 'center'}}
onClose={handleCloseSnackbar}
onClose={() => setSnackbar(prev => ({...prev, open: false}))}
>
<Alert variant='filled' severity={snackbar.severity} sx={{width: '100%'}} onClose={handleCloseSnackbar}>
<Alert
variant='filled'
severity={snackbar.severity}
sx={{width: '100%'}}
onClose={() => setSnackbar(prev => ({...prev, open: false}))}
>
{snackbar.message}
</Alert>
</Snackbar>
@@ -400,18 +317,17 @@ export default function VersionTimeline({
}
VersionTimeline.propTypes = {
collection: PropTypes.oneOf(['titres', 'articles']).isRequired,
data: PropTypes.array.isRequired,
accessToken: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired,
setError: PropTypes.func.isRequired,
setIsErrorAlertOpen: PropTypes.func.isRequired
setIsErrorAlertOpen: PropTypes.func.isRequired,
onVoteSuccess: PropTypes.func
}
VersionCard.propTypes = {
VersionItem.propTypes = {
version: PropTypes.object.isRequired,
index: PropTypes.number.isRequired,
totalVersions: PropTypes.number.isRequired,
accessToken: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired,
countdownRef: PropTypes.object.isRequired,
+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'
+111
View File
@@ -0,0 +1,111 @@
import {describe, it, expect} from 'vitest'
import {formatKonstitisyon, formatDate, hasRestrictedChar} from '../format.js'
describe('formatKonstitisyon', () => {
it('renvoie un tableau vide si aucun titre', () => {
expect(formatKonstitisyon([], [])).toEqual([])
})
it('regroupe les articles sous leur titre', () => {
const titres = [{id: 1, contenu: 'Titre I'}]
const articles = [
{id: 10, titre: 1, contenu: 'Art. 1'},
{id: 20, titre: 2, contenu: 'Art. 2'}, // autre titre — exclu
]
expect(formatKonstitisyon(titres, articles)).toEqual([
{titre: 'Titre I', titreId: 1, articles: [{id: 10, titre: 1, contenu: 'Art. 1'}]},
])
})
it('produit un article vide si aucun article pour ce titre', () => {
const titres = [{id: 1, contenu: 'Titre I'}]
expect(formatKonstitisyon(titres, [])).toEqual([
{titre: 'Titre I', titreId: 1, articles: []},
])
})
it('gère plusieurs titres et plusieurs articles', () => {
const titres = [
{id: 1, contenu: 'Titre I'},
{id: 2, contenu: 'Titre II'},
]
const articles = [
{id: 10, titre: 1},
{id: 11, titre: 1},
{id: 20, titre: 2},
]
const result = formatKonstitisyon(titres, articles)
expect(result).toHaveLength(2)
expect(result[0].articles).toHaveLength(2)
expect(result[1].articles).toHaveLength(1)
})
})
describe('formatDate', () => {
const fixedDate = new Date('2024-03-15T10:30:00Z')
it('renvoie une chaîne non vide par défaut', () => {
const result = formatDate(fixedDate)
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
})
it('ne contient pas de timezone par défaut', () => {
const result = formatDate(fixedDate)
expect(result).not.toMatch(/\(.+\)$/)
})
it('ajoute la timezone entre parenthèses quand withTimezone: true', () => {
const result = formatDate(fixedDate, 'PP', {withTimezone: true})
expect(result).toMatch(/\(.+\)$/)
})
it('utilise la locale française (contient un mois en français)', () => {
// date-fns format 'MMMM' en fr → mars / janvier / etc.
const result = formatDate(new Date('2024-01-15'), 'MMMM')
expect(result).toBe('janvier')
})
it('respecte le format personnalisé', () => {
const result = formatDate(new Date('2024-03-15'), 'dd/MM/yyyy')
expect(result).toBe('15/03/2024')
})
})
describe('hasRestrictedChar', () => {
it('détecte le caractère <', () => {
expect(hasRestrictedChar('<script>')).toBe(true)
})
it('détecte le caractère >', () => {
expect(hasRestrictedChar('a > b')).toBe(true)
})
it('détecte le caractère &', () => {
expect(hasRestrictedChar('a & b')).toBe(true)
})
it('détecte le caractère "', () => {
expect(hasRestrictedChar('"texte"')).toBe(true)
})
it('renvoie false pour un texte sans caractères restreints', () => {
expect(hasRestrictedChar('hello world')).toBe(false)
})
it('renvoie false pour une chaîne vide', () => {
expect(hasRestrictedChar('')).toBe(false)
})
it('renvoie false pour des apostrophes droites', () => {
expect(hasRestrictedChar('l\'article')).toBe(false)
})
})
+108
View File
@@ -0,0 +1,108 @@
import {
describe,
it,
expect,
beforeEach,
afterEach,
vi,
} from 'vitest'
import {createRateLimiter} from '../rate-limit.js'
describe('createRateLimiter', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(0) // Temps fixe et prévisible
})
afterEach(() => {
vi.useRealTimers()
})
it('autorise les requêtes dans la limite', () => {
const check = createRateLimiter({windowMs: 60_000, max: 3})
expect(check('ip1').success).toBe(true)
expect(check('ip1').success).toBe(true)
expect(check('ip1').success).toBe(true)
})
it('bloque quand la limite est atteinte', () => {
const check = createRateLimiter({windowMs: 60_000, max: 2})
check('ip1')
check('ip1')
const result = check('ip1')
expect(result.success).toBe(false)
})
it('renvoie retryAfter en secondes quand bloqué', () => {
const check = createRateLimiter({windowMs: 60_000, max: 1})
check('ip1') // start = 0
const result = check('ip1') // now = 0, retryAfter = ceil((0 + 60000 - 0) / 1000)
expect(result.success).toBe(false)
expect(result.retryAfter).toBe(60)
})
it('retryAfter décroît avec le temps écoulé', () => {
const check = createRateLimiter({windowMs: 60_000, max: 1})
check('ip1') // start = 0
vi.advanceTimersByTime(30_000) // 30s plus tard
const result = check('ip1') // retryAfter = ceil((0 + 60000 - 30000) / 1000) = 30
expect(result.success).toBe(false)
expect(result.retryAfter).toBe(30)
})
it('réinitialise le compteur après expiration de la fenêtre', () => {
const check = createRateLimiter({windowMs: 60_000, max: 1})
check('ip1')
expect(check('ip1').success).toBe(false)
vi.advanceTimersByTime(61_000) // Fenêtre expirée
expect(check('ip1').success).toBe(true)
})
it('suit les clés indépendamment', () => {
const check = createRateLimiter({windowMs: 60_000, max: 1})
check('ip1')
expect(check('ip1').success).toBe(false)
expect(check('ip2').success).toBe(true) // ip2 non affectée
})
it('réinitialise proprement à la requête suivante après expiration', () => {
const check = createRateLimiter({windowMs: 60_000, max: 2})
check('ip1')
check('ip1')
expect(check('ip1').success).toBe(false)
vi.advanceTimersByTime(61_000)
// Nouvelle fenêtre : 2 requêtes autorisées à nouveau
expect(check('ip1').success).toBe(true)
expect(check('ip1').success).toBe(true)
expect(check('ip1').success).toBe(false)
})
it('purge les entrées expirées lors du cleanup', () => {
const check = createRateLimiter({windowMs: 60_000, max: 5})
// Plusieurs IPs remplissent le store
check('ip1')
check('ip2')
check('ip3')
// Avancer d'une fenêtre complète pour déclencher le cleanup
vi.advanceTimersByTime(61_000)
// ip1 repart de zéro (entrée purgée)
expect(check('ip1').success).toBe(true)
})
})
+147
View File
@@ -0,0 +1,147 @@
import {describe, it, expect} from 'vitest'
import {filterVersions, getFilterStats} from '../version-utils.js'
// Usine à versions pour les tests
const makeVersion = (id, overrides = {}) => ({
id,
name: `Version ${id}`,
date_created: '2024-03-15T10:00:00Z',
user_created: `user-${id}-suffix`,
delta: {contenu: `Contenu de la version ${id}`},
...overrides,
})
describe('filterVersions', () => {
it('renvoie un tableau vide si versions est vide', () => {
expect(filterVersions([], '', {})).toEqual([])
})
it('renvoie un tableau vide si versions est null', () => {
expect(filterVersions(null, '', {})).toEqual([])
})
it('renvoie toutes les versions sans filtre ni recherche', () => {
const versions = [makeVersion(1), makeVersion(2)]
expect(filterVersions(versions, '', {})).toHaveLength(2)
})
describe('recherche textuelle (searchTerm)', () => {
it('filtre par contenu', () => {
const versions = [
makeVersion(1, {delta: {contenu: 'liberté égalité fraternité'}}),
makeVersion(2, {delta: {contenu: 'droits fondamentaux'}}),
]
const result = filterVersions(versions, 'liberté', {})
expect(result).toHaveLength(1)
expect(result[0].id).toBe(1)
})
it('filtre par nom de version', () => {
const versions = [
makeVersion(1, {name: 'Proposition Dupont'}),
makeVersion(2, {name: 'Version Martin'}),
]
const result = filterVersions(versions, 'dupont', {})
expect(result).toHaveLength(1)
expect(result[0].id).toBe(1)
})
it('filtre par auteur (préfixe du user_created)', () => {
const versions = [
makeVersion(1, {user_created: 'alice-uuid'}),
makeVersion(2, {user_created: 'bob-uuid'}),
]
const result = filterVersions(versions, 'alice', {})
expect(result).toHaveLength(1)
expect(result[0].id).toBe(1)
})
it('est insensible à la casse', () => {
const versions = [makeVersion(1, {name: 'Proposition Alpha'})]
expect(filterVersions(versions, 'ALPHA', {})).toHaveLength(1)
})
it('renvoie vide si aucune correspondance', () => {
const versions = [makeVersion(1), makeVersion(2)]
expect(filterVersions(versions, 'zzz-inexistant', {})).toHaveLength(0)
})
})
describe('filtre par auteur (filters.author)', () => {
it('ne retient que les versions de l\'auteur indiqué', () => {
const versions = [
makeVersion(1, {user_created: 'alice-uuid'}),
makeVersion(2, {user_created: 'bob-uuid'}),
]
const result = filterVersions(versions, '', {author: 'alice'})
expect(result).toHaveLength(1)
expect(result[0].id).toBe(1)
})
it('renvoie vide si aucune version de cet auteur', () => {
const versions = [makeVersion(1, {user_created: 'alice-uuid'})]
expect(filterVersions(versions, '', {author: 'bob'})).toHaveLength(0)
})
})
describe('filtre par plage de dates', () => {
const versions = [
makeVersion(1, {date_created: '2024-01-10T00:00:00Z'}),
makeVersion(2, {date_created: '2024-03-15T00:00:00Z'}),
makeVersion(3, {date_created: '2024-06-20T00:00:00Z'}),
]
it('filtre les versions antérieures à dateFrom', () => {
const result = filterVersions(versions, '', {dateFrom: '2024-03-01'})
expect(result.map(v => v.id)).toEqual([2, 3])
})
it('filtre les versions postérieures à dateTo', () => {
const result = filterVersions(versions, '', {dateTo: '2024-04-01'})
expect(result.map(v => v.id)).toEqual([1, 2])
})
it('combine dateFrom et dateTo', () => {
const result = filterVersions(versions, '', {dateFrom: '2024-02-01', dateTo: '2024-05-01'})
expect(result.map(v => v.id)).toEqual([2])
})
})
})
describe('getFilterStats', () => {
it('calcule les totaux correctement', () => {
const original = [makeVersion(1), makeVersion(2), makeVersion(3)]
const filtered = [makeVersion(1)]
expect(getFilterStats(original, filtered)).toEqual({
total: 3,
filtered: 1,
hidden: 2,
})
})
it('renvoie hidden: 0 quand rien n\'est filtré', () => {
const versions = [makeVersion(1), makeVersion(2)]
expect(getFilterStats(versions, versions)).toEqual({
total: 2,
filtered: 2,
hidden: 0,
})
})
it('gère les tableaux vides', () => {
expect(getFilterStats([], [])).toEqual({total: 0, filtered: 0, hidden: 0})
})
})
+42 -3
View File
@@ -141,9 +141,8 @@ export async function listVersions({
return versions
} catch (error) {
console.log('error', error)
if (error?.errors[0]?.message === 'Token expired.') {
if (error) {
countdownRef.current.startCountdown()
} else {
console.log(error?.errors[0]?.message)
@@ -370,3 +369,43 @@ export async function getUserVote({
throw error
}
}
export async function getVoteCounts({
accessToken,
versionId
}) {
try {
const votes = await directusClient.request(
withToken(
accessToken,
readItems('votes', {
filter: {
content_version_id: {_eq: versionId}
},
fields: ['vote']
})
)
)
const counts = {
positive: 0,
negative: 0,
total: 0
}
for (const v of votes) {
if (v.vote === 1) {
counts.positive++
} else if (v.vote === -1) {
counts.negative++
}
}
counts.total = counts.positive - counts.negative
return counts
} catch (error) {
console.error('Error fetching vote counts:', error)
return {positive: 0, negative: 0, total: 0}
}
}
+9 -2
View File
@@ -19,10 +19,17 @@ export function formatKonstitisyon(titres, articles) {
return konstitisyon
}
export function formatDate(date, formatStr = 'PP') {
return format(date, formatStr, {
export function formatDate(date, formatStr = 'PP', {withTimezone = false} = {}) {
const formatted = format(date, formatStr, {
locale: fr
})
if (withTimezone) {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
return `${formatted} (${timezone})`
}
return formatted
}
export function hasRestrictedChar(text) {
+61
View File
@@ -0,0 +1,61 @@
/**
* Fabrique de rate-limiter en mémoire.
*
* Adapté à un déploiement single-process (PM2 sans cluster).
* Pour un déploiement multi-instances, remplacer le Map par un store
* Redis partagé (ex. @upstash/ratelimit).
*
* @param {object} options
* @param {number} options.windowMs - Durée de la fenêtre en millisecondes
* @param {number} options.max - Nombre maximum de requêtes par fenêtre
*/
export function createRateLimiter({windowMs, max}) {
const store = new Map()
let lastCleanup = Date.now()
/**
* Supprime les entrées expirées du store.
* Appelé automatiquement une fois par fenêtre temporelle.
*/
function cleanup(now) {
for (const [storeKey, entry] of store) {
if (now - entry.start >= windowMs) {
store.delete(storeKey)
}
}
lastCleanup = now
}
/**
* Vérifie si la clé (IP:route) dépasse la limite autorisée.
*
* @param {string} key - Identifiant unique (ex. "1.2.3.4:/api/auth/register")
* @returns {{ success: boolean, retryAfter?: number }}
*/
return key => {
const now = Date.now()
if (now - lastCleanup >= windowMs) {
cleanup(now)
}
const entry = store.get(key)
// Première requête ou fenêtre expirée : on repart à zéro
if (!entry || now - entry.start >= windowMs) {
store.set(key, {count: 1, start: now})
return {success: true}
}
// Limite atteinte
if (entry.count >= max) {
const retryAfter = Math.ceil((entry.start + windowMs - now) / 1000)
return {success: false, retryAfter}
}
// Incrément normal
store.set(key, {...entry, count: entry.count + 1})
return {success: true}
}
}
+98
View File
@@ -0,0 +1,98 @@
/** @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\''
const UNSAFE_INLINE = '\'unsafe-inline\''
// Content Security Policy
// - unsafe-inline sur script-src : requis par Next.js App Router (hydratation inline)
// - unsafe-inline sur style-src : requis par Emotion/MUI (styles injectés dynamiquement)
// - data: blob: sur img-src : requis par html2canvas (export PDF)
// - frame-ancestors 'none' : anti-clickjacking (complète X-Frame-Options)
const cspDirectives = [
`default-src ${SELF}`,
`script-src ${SELF} ${UNSAFE_INLINE}`,
`style-src ${SELF} ${UNSAFE_INLINE}`,
`connect-src ${SELF} ${apiUrl} ${wsUrl} ${derivedWsUrl}`.trim().replace(/ {2,}/g, ' '),
`img-src ${SELF} data: blob:`,
`font-src ${SELF}`,
`object-src ${NONE}`,
`base-uri ${SELF}`,
`form-action ${SELF}`,
`frame-ancestors ${NONE}`,
]
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: cspDirectives.join('; '),
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
]
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'],
},
// Réduire l'utilisation mémoire
compress: true,
// Désactiver les source maps en dev pour économiser la mémoire
productionBrowserSourceMaps: false,
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
]
},
}
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',
})
+43 -17
View File
@@ -3,32 +3,40 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "xo"
"lint": "xo",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@directus/sdk": "^18.0.1",
"@emotion/cache": "^11.13.5",
"@emotion/react": "^11.13.5",
"@emotion/styled": "^11.13.5",
"@fontsource/roboto": "^5.1.0",
"@mui/icons-material": "^6.1.9",
"@mui/lab": "^7.0.0-beta.14",
"@mui/material": "^6.1.9",
"@mui/material-nextjs": "^6.1.9",
"@uiw/react-md-editor": "^4.0.8",
"date-fns": "^3.6.0",
"@directus/sdk": "^20.3.0",
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.2.9",
"@mui/icons-material": "^7.3.6",
"@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",
"jspdf": "^3.0.1",
"marked": "^16.1.1",
"jspdf": "^4.0.0",
"marked": "^17.0.1",
"next": "^16.1.0",
"next-auth": "5.0.0-beta.25",
"next-auth": "^5.0.0-beta.30",
"node-gyp": "^12.3.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-virtuoso": "^4.10.2",
"react-virtuoso": "^4.18.1",
"sharp": "^0.34.5",
"use-debounce": "^10.0.5"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.4",
"eslint-config-xo-nextjs": "^6.0.0",
"vitest": "^4.1.4",
"xo": "^0.58.0"
},
"xo": {
@@ -49,6 +57,24 @@
"n/prefer-global/process": "off",
"comma-dangle": "off",
"unicorn/prevent-abbreviations": "off"
}
},
"overrides": [
{
"files": "lib/__tests__/**/*.js",
"envs": [
"node",
"es2020"
],
"rules": {
"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,
})
+18
View File
@@ -0,0 +1,18 @@
# Leçons — Konstitisyon Frontend
## 2026-04-13 — Rate limiting / Premier chantier
### Erreur commise
Implémentation directe sans passer par le mode planification ni créer `tasks/todo.md` avant de coder.
La tâche a été présentée comme terminée après validation XO, sans preuve de fonctionnement réel.
### Règle à retenir
1. **Toujours** créer/mettre à jour `tasks/todo.md` avant de toucher au code pour toute tâche 3+ étapes.
2. **Toujours** prouver le fonctionnement après implémentation : test unitaire, `curl`, ou vérification Node directe — pas seulement le linter.
3. Le linter qui passe ≠ la feature qui fonctionne.
### Ce qui a bien fonctionné
- Lecture complète des fichiers impactés avant d'écrire (middleware, register, options, package.json)
- Zéro dépendance externe ajoutée — solution en-mémoire adaptée à PM2 single-process
- Correction autonome des erreurs XO (curly, arrow-parens, func-names) sans intervention
- Abstraction propre : `createRateLimiter` est réutilisable pour d'autres routes futures
+27
View File
@@ -0,0 +1,27 @@
# Tâches — Konstitisyon Frontend
## Améliorations critiques (P1)
- [x] **Rate limiting**`lib/rate-limit.js` + `middleware.js`
- Routes protégées : `/api/auth/register` (5/15min) et `/api/auth/callback/credentials` (10/5min)
- Logique vérifiée : comptage, blocage 429 + Retry-After, expiration fenêtre ✓
- [x] **CORS whitelist** — restreindre `CORS_ORIGIN=true` dans l'env Directus
- [x] **Sanitisation Markdown** — DOMPurify sur la sortie `marked` dans export-pdf et print-button
## Améliorations hautes (P2)
- [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`
- [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)
- [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)
+14
View File
@@ -0,0 +1,14 @@
import {defineConfig} from 'vitest/config'
export default defineConfig({
test: {
// Imports explicites (pas de globals) — cohérent avec le style XO du projet
globals: false,
environment: 'node',
coverage: {
provider: 'v8',
include: ['lib/**/*.js'],
exclude: ['lib/__tests__/**'],
},
},
})
+2653 -1060
View File
File diff suppressed because it is too large Load Diff