Compare commits

16 Commits

Author SHA1 Message Date
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
31 changed files with 13391 additions and 482 deletions
+3
View File
@@ -16,3 +16,6 @@ NEXT_PUBLIC_DIRECTUS_API_WS_URL=$DIRECTUS_API_WS_URL
# COMMENTS # COMMENTS
COMMENTS_PER_PAGE=5 COMMENTS_PER_PAGE=5
# WEBSOCKET
NEXT_PUBLIC_DISABLE_WEBSOCKET=false
+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
```
+1 -1
View File
@@ -34,7 +34,7 @@ async function getData() {
_eq: 'published' _eq: 'published'
} }
}, },
sort: 'numero' sort: 'date_created'
}) })
) )
@@ -22,7 +22,7 @@ export default function HandleCreate({
useEffect(() => { useEffect(() => {
if (listItems && listItems.length > 0) { if (listItems && listItems.length > 0) {
setSelectValue(listItems[0].id) setSelectValue(listItems.at(-1).id)
} }
}, [listItems]) }, [listItems])
@@ -142,6 +142,7 @@ export default function HandleCreate({
collection={collection} collection={collection}
listItems={listItems} listItems={listItems}
handleFormSubmit={handleFormSubmit} handleFormSubmit={handleFormSubmit}
selectValue={selectValue}
setSelectValue={setSelectValue} setSelectValue={setSelectValue}
title='Article' title='Article'
dialogText='Écrivez votre 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 FormControl from '@mui/material/FormControl'
import NativeSelect from '@mui/material/NativeSelect' import NativeSelect from '@mui/material/NativeSelect'
export default function ListItems({items, selectLabel, setSelectValue}) { export default function ListItems({items, selectLabel, selectValue, setSelectValue}) {
const handleChange = event => { const handleChange = event => {
setSelectValue(event.target.value) setSelectValue(event.target.value)
} }
@@ -16,7 +16,7 @@ export default function ListItems({items, selectLabel, setSelectValue}) {
{selectLabel} {selectLabel}
</InputLabel> </InputLabel>
<NativeSelect <NativeSelect
defaultValue='' value={selectValue}
inputProps={{ inputProps={{
name: 'content', name: 'content',
id: 'titre', id: 'titre',
@@ -35,5 +35,6 @@ export default function ListItems({items, selectLabel, setSelectValue}) {
ListItems.propTypes = { ListItems.propTypes = {
items: PropTypes.array.isRequired, items: PropTypes.array.isRequired,
selectLabel: PropTypes.string.isRequired, selectLabel: PropTypes.string.isRequired,
selectValue: PropTypes.string.isRequired,
setSelectValue: PropTypes.func.isRequired setSelectValue: PropTypes.func.isRequired
} }
+3
View File
@@ -24,6 +24,7 @@ export default function FormHandler({
listItems, listItems,
handleFormSubmit, handleFormSubmit,
countdownRef, countdownRef,
selectValue,
setSelectValue, setSelectValue,
contenu, contenu,
collection collection
@@ -51,6 +52,7 @@ export default function FormHandler({
<ListItems <ListItems
items={listItems} items={listItems}
selectLabel='Titre associé *' selectLabel='Titre associé *'
selectValue={selectValue}
setSelectValue={setSelectValue} setSelectValue={setSelectValue}
/> />
)} )}
@@ -94,6 +96,7 @@ FormHandler.propTypes = {
setError: PropTypes.func.isRequired, setError: PropTypes.func.isRequired,
setIsErrorAlertOpen: PropTypes.func.isRequired, setIsErrorAlertOpen: PropTypes.func.isRequired,
handleFormSubmit: PropTypes.func.isRequired, handleFormSubmit: PropTypes.func.isRequired,
selectValue: PropTypes.string,
setSelectValue: PropTypes.func.isRequired, setSelectValue: PropTypes.func.isRequired,
dialogText: PropTypes.string.isRequired, dialogText: PropTypes.string.isRequired,
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
+2 -2
View File
@@ -116,6 +116,6 @@ export default function Konstitisyon({session, titres, articles}) {
Konstitisyon.propTypes = { Konstitisyon.propTypes = {
session: PropTypes.object, session: PropTypes.object,
titres: PropTypes.object.isRequired, titres: PropTypes.array.isRequired,
articles: PropTypes.object.isRequired articles: PropTypes.array.isRequired
} }
+22 -1
View File
@@ -10,6 +10,7 @@ import Typography from '@mui/material/Typography'
import Pagination from '@mui/material/Pagination' import Pagination from '@mui/material/Pagination'
import Divider from '@mui/material/Divider' import Divider from '@mui/material/Divider'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
import {readItems, withToken} from '@directus/sdk' import {readItems, withToken} from '@directus/sdk'
import SessionExpired from '../session/session-expired.js' import SessionExpired from '../session/session-expired.js'
import {directusClient, handleUserStatus} from '@/lib/directus.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}) { export default function ListComments({session, selectedTitre, isOpen, setIsOpen, setError, setIsErrorAlertOpen}) {
const countdownRef = useRef() const countdownRef = useRef()
const [comments, setComments] = useState([]) const [comments, setComments] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const pageCount = Math.ceil(comments.length / commentsPerPage) 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 startIndex = (page - 1) * commentsPerPage
const selectedComments = comments.slice(startIndex, startIndex + commentsPerPage) const selectedComments = comments.slice(startIndex, startIndex + commentsPerPage)
useEffect(() => {
setComments([])
setPage(1)
}, [selectedTitre?.id])
useEffect(() => { useEffect(() => {
async function fetchComments() { async function fetchComments() {
setIsLoading(true)
try { try {
await handleUserStatus(session.user.accessToken, session.user.userId) 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) setError(error?.errors[0]?.message)
setIsErrorAlertOpen(true) setIsErrorAlertOpen(true)
} }
} finally {
setIsLoading(false)
} }
} }
@@ -74,6 +85,12 @@ export default function ListComments({session, selectedTitre, isOpen, setIsOpen,
<> <>
<Dialog open={isOpen} onClose={handleClose}> <Dialog open={isOpen} onClose={handleClose}>
<DialogTitle>Commentaires</DialogTitle> <DialogTitle>Commentaires</DialogTitle>
{isLoading ? (
<Box sx={{display: 'flex', justifyContent: 'center', p: 4}}>
<CircularProgress />
</Box>
) : (
<>
<List sx={{width: '100%', maxWidth: 360, bgcolor: 'background.paper'}}> <List sx={{width: '100%', maxWidth: 360, bgcolor: 'background.paper'}}>
{selectedComments && selectedComments.length > 0 ? selectedComments.map(({id, date_created, contenu, user_created}) => ( {selectedComments && selectedComments.length > 0 ? selectedComments.map(({id, date_created, contenu, user_created}) => (
<React.Fragment key={id}> <React.Fragment key={id}>
@@ -104,12 +121,16 @@ export default function ListComments({session, selectedTitre, isOpen, setIsOpen,
<Divider component='li' /> <Divider component='li' />
</React.Fragment> </React.Fragment>
)) : ( )) : (
<Typography textAlign='center'>Aucun commentaire</Typography> <Typography textAlign='center' sx={{p: 2}}>Aucun commentaire</Typography>
)} )}
</List> </List>
{pageCount > 1 && (
<Box sx={{display: 'flex', justifyContent: 'center'}}> <Box sx={{display: 'flex', justifyContent: 'center'}}>
<Pagination size='small' sx={{marginBlock: 3}} color='success' count={pageCount} page={page} onChange={handleChange} /> <Pagination size='small' sx={{marginBlock: 3}} color='success' count={pageCount} page={page} onChange={handleChange} />
</Box> </Box>
)}
</>
)}
</Dialog> </Dialog>
<SessionExpired ref={countdownRef} setError={setError} setIsErrorAlertOpen={setIsErrorAlertOpen} /> <SessionExpired ref={countdownRef} setError={setError} setIsErrorAlertOpen={setIsErrorAlertOpen} />
</> </>
+5
View File
@@ -16,6 +16,7 @@ import {createDirectus, realtime, staticToken} from '@directus/sdk'
import ConfirmationAlert from './confirmation-alert.js' import ConfirmationAlert from './confirmation-alert.js'
const apiUrl = process.env.DIRECTUS_API_URL || process.env.NEXT_PUBLIC_DIRECTUS_API_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}) => ( const LightTooltip = styled(({className, ...props}) => (
<Tooltip {...props} classes={{popper: className}} /> <Tooltip {...props} classes={{popper: className}} />
@@ -40,6 +41,10 @@ export default function Sign({session, navButton}) {
useEffect(() => { useEffect(() => {
let cleanup = () => {} let cleanup = () => {}
if (disableWebSocket) {
return () => cleanup()
}
if (session?.user?.accessToken) { if (session?.user?.accessToken) {
(async () => { (async () => {
try { try {
+70 -6
View File
@@ -29,8 +29,11 @@ const renderMarkdownToHtml = async content => {
} }
try { try {
// Dynamic import of markdown parser // Dynamic import of markdown parser and sanitizer
const {marked} = await import('marked') const [{marked}, {default: DOMPurify}] = await Promise.all([
import('marked'),
import('dompurify')
])
// Configure marked for better PDF rendering // Configure marked for better PDF rendering
marked.setOptions({ marked.setOptions({
breaks: true, // Convert \n to <br> breaks: true, // Convert \n to <br>
@@ -39,14 +42,49 @@ const renderMarkdownToHtml = async content => {
mangle: false // Don't mangle email addresses 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) { } catch (error) {
console.warn('Failed to parse markdown, falling back to plain text:', error) console.warn('Failed to parse markdown, falling back to plain text:', error)
return content.replaceAll('\n', '<br>') return content.replaceAll('\n', '<br>')
} }
} }
export default function ExportPdfButton({versionData, isOutdated = false, size = 'medium', variant = 'outlined'}) { export default function ExportPdfButton({versionData, isOutdated = false, voteCounts = null, size = 'medium', variant = 'outlined'}) {
const [isExporting, setIsExporting] = useState(false) const [isExporting, setIsExporting] = useState(false)
const handleExportPdf = async () => { const handleExportPdf = async () => {
@@ -146,6 +184,11 @@ export default function ExportPdfButton({versionData, isOutdated = false, size =
const voteStatus = (isExpired || isOutdated) ? 'fermé' : 'ouvert' const voteStatus = (isExpired || isOutdated) ? 'fermé' : 'ouvert'
const voteColor = voteStatus === 'ouvert' ? '#2e7d32' : '#d32f2f' 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 // Render markdown content to HTML
const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu) const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu)
@@ -159,12 +202,28 @@ export default function ExportPdfButton({versionData, isOutdated = false, size =
<strong>Auteur :</strong> @${authorName} <strong>Auteur :</strong> @${authorName}
</p> </p>
<p style="margin: 5px 0; color: #666; font-size: 14px;"> <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>
<p style="margin: 5px 0; color: #666; font-size: 14px;"> <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> <span style="color: ${voteColor}; font-weight: bold;">${voteStatus}</span>
</p> </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>
</div> </div>
@@ -178,7 +237,7 @@ export default function ExportPdfButton({versionData, isOutdated = false, size =
</div> </div>
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #888; text-align: center;"> <div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #888; text-align: center;">
Exporté depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp')} Exporté depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp', {withTimezone: true})}
</div> </div>
` `
@@ -253,6 +312,11 @@ export default function ExportPdfButton({versionData, isOutdated = false, size =
ExportPdfButton.propTypes = { ExportPdfButton.propTypes = {
versionData: PropTypes.object.isRequired, versionData: PropTypes.object.isRequired,
isOutdated: PropTypes.bool, isOutdated: PropTypes.bool,
voteCounts: PropTypes.shape({
positive: PropTypes.number,
negative: PropTypes.number,
total: PropTypes.number
}),
size: PropTypes.oneOf(['small', 'medium', 'large']), size: PropTypes.oneOf(['small', 'medium', 'large']),
variant: PropTypes.oneOf(['text', 'outlined', 'contained']) variant: PropTypes.oneOf(['text', 'outlined', 'contained'])
} }
+49 -12
View File
@@ -24,7 +24,7 @@ import ShareButton from './share-button.js'
import ExportPdfButton from './export-pdf-button.js' import ExportPdfButton from './export-pdf-button.js'
import PrintButton from './print-button.js' import PrintButton from './print-button.js'
import {formatDate} from '@/lib/format.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' import {filterVersions, getFilterStats} from '@/lib/version-utils.js'
const columns = [ const columns = [
@@ -85,7 +85,8 @@ function rowContent({
setIsErrorAlertOpen, setIsErrorAlertOpen,
setIsOpenComparison, setIsOpenComparison,
setVersionCompare, setVersionCompare,
outdatedStatusMap outdatedStatusMap,
voteCountsMap
}) { }) {
const handleButtonClick = async versionId => { const handleButtonClick = async versionId => {
const version = await compareVersion({ const version = await compareVersion({
@@ -104,6 +105,7 @@ function rowContent({
} }
const isOutdated = outdatedStatusMap[row.id] || false const isOutdated = outdatedStatusMap[row.id] || false
const voteCounts = voteCountsMap[row.id] || null
return ( return (
<> <>
@@ -141,12 +143,14 @@ function rowContent({
<ExportPdfButton <ExportPdfButton
versionData={row} versionData={row}
isOutdated={isOutdated} isOutdated={isOutdated}
voteCounts={voteCounts}
size='small' size='small'
variant='text' variant='text'
/> />
<PrintButton <PrintButton
versionData={row} versionData={row}
isOutdated={isOutdated} isOutdated={isOutdated}
voteCounts={voteCounts}
size='small' size='small'
variant='text' variant='text'
/> />
@@ -188,11 +192,13 @@ export default function ListVersions({
status: '' status: ''
}) })
const [outdatedStatusMap, setOutdatedStatusMap] = useState({}) const [outdatedStatusMap, setOutdatedStatusMap] = useState({})
const [voteCountsMap, setVoteCountsMap] = useState({})
// Fetch outdated status for all versions // Fetch outdated status and vote counts for all versions
useEffect(() => { useEffect(() => {
async function fetchOutdatedStatus() { async function fetchVersionsData() {
const statusMap = {} const statusMap = {}
const countsMap = {}
await Promise.all( await Promise.all(
data.map(async version => { data.map(async version => {
@@ -209,18 +215,27 @@ export default function ListVersions({
if (comparisonData) { if (comparisonData) {
statusMap[version.id] = comparisonData.outdated || false statusMap[version.id] = comparisonData.outdated || false
} }
// Fetch vote counts
const counts = await getVoteCounts({
accessToken,
versionId: version.id
})
countsMap[version.id] = counts
} catch (error) { } catch (error) {
console.warn(`Failed to fetch outdated status for version ${version.id}:`, error) console.warn(`Failed to fetch data for version ${version.id}:`, error)
statusMap[version.id] = false statusMap[version.id] = false
countsMap[version.id] = {positive: 0, negative: 0, total: 0}
} }
}) })
) )
setOutdatedStatusMap(statusMap) setOutdatedStatusMap(statusMap)
setVoteCountsMap(countsMap)
} }
if (data.length > 0) { if (data.length > 0) {
fetchOutdatedStatus() fetchVersionsData()
} }
}, [data, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen]) }, [data, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen])
@@ -230,6 +245,19 @@ export default function ListVersions({
const versionData = data.find(({id}) => id === versionCompare?.versionId) 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 => { const handleSearchChange = newSearchTerm => {
setSearchTerm(newSearchTerm) setSearchTerm(newSearchTerm)
} }
@@ -249,8 +277,10 @@ export default function ListVersions({
<Box> <Box>
<Box sx={{ <Box sx={{
display: 'flex', display: 'flex',
flexDirection: {xs: 'column', sm: 'row'},
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: {xs: 'stretch', sm: 'center'},
gap: 1,
mb: 2 mb: 2
}} }}
> >
@@ -271,13 +301,14 @@ export default function ListVersions({
size='small' size='small'
value={viewMode} value={viewMode}
onChange={handleViewModeChange} onChange={handleViewModeChange}
sx={{alignSelf: {xs: 'center', sm: 'auto'}}}
> >
<ToggleButton value='table' aria-label='vue tableau'> <ToggleButton value='table' aria-label='vue tableau'>
<ViewListIcon sx={{mr: 1}} /> <ViewListIcon fontSize='small' sx={{mr: 0.5}} />
Table Table
</ToggleButton> </ToggleButton>
<ToggleButton value='timeline' aria-label='vue chronologique'> <ToggleButton value='timeline' aria-label='vue chronologique'>
<TimelineIcon sx={{mr: 1}} /> <TimelineIcon fontSize='small' sx={{mr: 0.5}} />
Timeline Timeline
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
@@ -302,25 +333,31 @@ export default function ListVersions({
components={VirtuosoTableComponents} components={VirtuosoTableComponents}
fixedHeaderContent={fixedHeaderContent} fixedHeaderContent={fixedHeaderContent}
itemContent={(index, row) => rowContent({ itemContent={(index, row) => rowContent({
index, row, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen, setIsOpenComparison, setVersionCompare, outdatedStatusMap index, row, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen, setIsOpenComparison, setVersionCompare, outdatedStatusMap, voteCountsMap
})} })}
/> />
</Paper> </Paper>
) : ( ) : (
<VersionTimeline <VersionTimeline
collection={collection}
data={filteredData} data={filteredData}
accessToken={accessToken} accessToken={accessToken}
userId={userId} userId={userId}
setError={setError} setError={setError}
setIsErrorAlertOpen={setIsErrorAlertOpen} setIsErrorAlertOpen={setIsErrorAlertOpen}
onVoteSuccess={refreshVoteCounts}
/> />
) )
)} )}
</Box> </Box>
{isOpenComparison && ( {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} /> <SessionExpired ref={countdownRef} setError={setError} setIsErrorAlertOpen={setIsErrorAlertOpen} />
</> </>
+70 -6
View File
@@ -31,8 +31,11 @@ const renderMarkdownToHtml = async content => {
} }
try { try {
// Dynamic import of markdown parser // Dynamic import of markdown parser and sanitizer
const {marked} = await import('marked') const [{marked}, {default: DOMPurify}] = await Promise.all([
import('marked'),
import('dompurify')
])
// Configure marked for better print rendering // Configure marked for better print rendering
marked.setOptions({ marked.setOptions({
@@ -42,14 +45,49 @@ const renderMarkdownToHtml = async content => {
mangle: false // Don't mangle email addresses 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) { } catch (error) {
console.warn('Failed to parse markdown, falling back to plain text:', error) console.warn('Failed to parse markdown, falling back to plain text:', error)
return content.replaceAll('\n', '<br>') return content.replaceAll('\n', '<br>')
} }
} }
export default function PrintButton({versionData, isOutdated = false, size = 'medium', variant = 'outlined'}) { export default function PrintButton({versionData, isOutdated = false, voteCounts = null, size = 'medium', variant = 'outlined'}) {
const [isPrinting, setIsPrinting] = useState(false) const [isPrinting, setIsPrinting] = useState(false)
const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'}) const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'})
@@ -65,6 +103,11 @@ export default function PrintButton({versionData, isOutdated = false, size = 'me
const voteStatus = (isExpired || isOutdated) ? 'fermé' : 'ouvert' const voteStatus = (isExpired || isOutdated) ? 'fermé' : 'ouvert'
const voteColor = voteStatus === 'ouvert' ? '#2e7d32' : '#d32f2f' 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 // Render markdown content to HTML
const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu) const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu)
@@ -303,12 +346,28 @@ export default function PrintButton({versionData, isOutdated = false, size = 'me
<strong>Auteur :</strong> @${authorName} <strong>Auteur :</strong> @${authorName}
</div> </div>
<div class="metadata"> <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>
<div class="metadata"> <div class="metadata">
<strong>Statut du vote :</strong> <strong>Statut du vote :</strong>
<span class="vote-status">${voteStatus}</span> <span class="vote-status">${voteStatus}</span>
</div> </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>
<div class="content-section"> <div class="content-section">
@@ -319,7 +378,7 @@ export default function PrintButton({versionData, isOutdated = false, size = 'me
</div> </div>
<div class="footer"> <div class="footer">
Imprimé depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp')} Imprimé depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp', {withTimezone: true})}
</div> </div>
</body> </body>
</html> </html>
@@ -392,6 +451,11 @@ export default function PrintButton({versionData, isOutdated = false, size = 'me
PrintButton.propTypes = { PrintButton.propTypes = {
versionData: PropTypes.object.isRequired, versionData: PropTypes.object.isRequired,
isOutdated: PropTypes.bool, isOutdated: PropTypes.bool,
voteCounts: PropTypes.shape({
positive: PropTypes.number,
negative: PropTypes.number,
total: PropTypes.number
}),
size: PropTypes.oneOf(['small', 'medium', 'large']), size: PropTypes.oneOf(['small', 'medium', 'large']),
variant: PropTypes.oneOf(['text', 'outlined', 'contained']) variant: PropTypes.oneOf(['text', 'outlined', 'contained'])
} }
+64 -8
View File
@@ -2,20 +2,54 @@ import Box from '@mui/material/Box'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import Paper from '@mui/material/Paper' import Paper from '@mui/material/Paper'
import Grid from '@mui/material/Grid' import Grid from '@mui/material/Grid'
import Chip from '@mui/material/Chip'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import Snackbar from '@mui/material/Snackbar' import Snackbar from '@mui/material/Snackbar'
import Alert from '@mui/material/Alert' 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 MarkdownRenderer from '../markdown-renderer/index.js'
import VoteButtons from './vote-buttons.js' import VoteButtons from './vote-buttons.js'
import CopyButton from './copy-button.js' import CopyButton from './copy-button.js'
import {formatDate} from '@/lib/format.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 {current, main, outdated} = versionCompare
const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'}) 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) { if (onVoteResult) {
// Use the parent's vote result handler if provided // Use the parent's vote result handler if provided
onVoteResult(result) onVoteResult(result)
@@ -35,7 +69,8 @@ export default function VersionComparison({versionData, versionCompare, voteRefr
const createdAt = new Date(versionData.date_created) const createdAt = new Date(versionData.date_created)
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000)) const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
const isVoteDisabled = createdAt < threeDaysAgo const isExpired = createdAt < threeDaysAgo
const isVoteDisabled = isExpired || outdated
return ( return (
<Box sx={{padding: 3}}> <Box sx={{padding: 3}}>
@@ -106,9 +141,8 @@ export default function VersionComparison({versionData, versionCompare, voteRefr
@{versionData.user_created?.split('-')[0] || 'Système'} @{versionData.user_created?.split('-')[0] || 'Système'}
</Typography> </Typography>
</Box> </Box>
{!outdated && (
<Box sx={{ <Box sx={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1 display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1, flexWrap: 'wrap', gap: 1
}} }}
> >
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}> <Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
@@ -123,14 +157,35 @@ export default function VersionComparison({versionData, versionCompare, voteRefr
hasSnackbarVisible={false} hasSnackbarVisible={false}
/> />
</Box> </Box>
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
<VoteButtons <VoteButtons
key={`vote-comparison-${voteRefreshKey}`} key={`vote-comparison-${voteRefreshKey}`}
versionId={versionCompare.versionId} versionId={versionCompare.versionId}
isDisabled={isVoteDisabled} isDisabled={isVoteDisabled}
onVoteResult={handleVoteResult} 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> </Box>
)}
</Paper> </Paper>
</Grid> </Grid>
</Grid> </Grid>
@@ -214,5 +269,6 @@ VersionComparison.propTypes = {
versionId: PropTypes.string versionId: PropTypes.string
}).isRequired, }).isRequired,
voteRefreshKey: PropTypes.number, 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 {useTheme} from '@mui/material/styles'
import VersionComparison from './version-comparison.js' 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 theme = useTheme()
const fullScreen = useMediaQuery(theme.breakpoints.down('md')) const fullScreen = useMediaQuery(theme.breakpoints.down('md'))
@@ -59,7 +59,7 @@ export default function VersionDialog({versionData, versionCompare, isOpen, setI
</DialogTitle> </DialogTitle>
<DialogContent sx={{minHeight: '60vh'}}> <DialogContent sx={{minHeight: '60vh'}}>
<VersionComparison versionData={versionData} versionCompare={versionCompare} /> <VersionComparison versionData={versionData} versionCompare={versionCompare} onVoteSuccess={onVoteSuccess} />
</DialogContent> </DialogContent>
<DialogActions sx={{px: 3, py: 2}}> <DialogActions sx={{px: 3, py: 2}}>
@@ -84,5 +84,6 @@ VersionDialog.propTypes = {
main: PropTypes.object.isRequired main: PropTypes.object.isRequired
}).isRequired, }).isRequired,
isOpen: PropTypes.bool.isRequired, isOpen: PropTypes.bool.isRequired,
setIsOpen: PropTypes.func.isRequired setIsOpen: PropTypes.func.isRequired,
onVoteSuccess: PropTypes.func
} }
+20 -4
View File
@@ -24,7 +24,7 @@ import CopyButton from './copy-button.js'
import ExportPdfButton from './export-pdf-button.js' import ExportPdfButton from './export-pdf-button.js'
import PrintButton from './print-button.js' import PrintButton from './print-button.js'
import VersionComparison from './version-comparison.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' import {formatDate} from '@/lib/format.js'
export default function VersionPage({session, versionId, viewMode}) { export default function VersionPage({session, versionId, viewMode}) {
@@ -39,6 +39,7 @@ export default function VersionPage({session, versionId, viewMode}) {
const [isErrorAlertOpen, setIsErrorAlertOpen] = useState(false) const [isErrorAlertOpen, setIsErrorAlertOpen] = useState(false)
const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'}) const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'})
const [voteRefreshKey, setVoteRefreshKey] = useState(0) const [voteRefreshKey, setVoteRefreshKey] = useState(0)
const [voteCounts, setVoteCounts] = useState(null)
useEffect(() => { useEffect(() => {
async function fetchVersionData() { async function fetchVersionData() {
@@ -67,6 +68,13 @@ export default function VersionPage({session, versionId, viewMode}) {
if (comparison) { if (comparison) {
setVersionCompare({...comparison, versionId}) setVersionCompare({...comparison, versionId})
} }
const counts = await getVoteCounts({
accessToken,
versionId
})
setVoteCounts(counts)
} catch (error) { } catch (error) {
console.error('Failed to fetch version:', error) console.error('Failed to fetch version:', error)
setError('Impossible de charger cette version') setError('Impossible de charger cette version')
@@ -83,7 +91,7 @@ export default function VersionPage({session, versionId, viewMode}) {
router.push('/dashboard') router.push('/dashboard')
} }
const handleVoteResult = result => { const handleVoteResult = async result => {
setSnackbar({ setSnackbar({
open: true, open: true,
message: result.message, message: result.message,
@@ -91,6 +99,14 @@ export default function VersionPage({session, versionId, viewMode}) {
}) })
// Force refresh of both VoteButtons components by changing the key // Force refresh of both VoteButtons components by changing the key
setVoteRefreshKey(prev => prev + 1) setVoteRefreshKey(prev => prev + 1)
if (result.success) {
const counts = await getVoteCounts({
accessToken,
versionId
})
setVoteCounts(counts)
}
} }
const handleCloseSnackbar = () => { const handleCloseSnackbar = () => {
@@ -222,8 +238,8 @@ export default function VersionPage({session, versionId, viewMode}) {
</Button> </Button>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}> <Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<ExportPdfButton versionData={versionData} size='medium' /> <ExportPdfButton versionData={versionData} isOutdated={versionCompare?.outdated} voteCounts={voteCounts} size='medium' />
<PrintButton versionData={versionData} size='medium' /> <PrintButton versionData={versionData} isOutdated={versionCompare?.outdated} voteCounts={voteCounts} size='medium' />
<Tooltip title='Partager cette version'> <Tooltip title='Partager cette version'>
<IconButton color='primary' onClick={handleShare}> <IconButton color='primary' onClick={handleShare}>
<ShareIcon /> <ShareIcon />
+131 -299
View File
@@ -1,118 +1,36 @@
import {useRef, useState, useEffect} from 'react' import {useRef, useState, useEffect} from 'react'
import {useTheme} from '@mui/material/styles'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import Card from '@mui/material/Card' import IconButton from '@mui/material/IconButton'
import CardContent from '@mui/material/CardContent' import Collapse from '@mui/material/Collapse'
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 Snackbar from '@mui/material/Snackbar' import Snackbar from '@mui/material/Snackbar'
import Alert from '@mui/material/Alert' 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 SessionExpired from '../session/session-expired.js'
import MarkdownRenderer from '../markdown-renderer/index.js'
import VersionDialog from './version-dialog.js' import VersionDialog from './version-dialog.js'
import VoteButtons from './vote-buttons.js' import VoteButtons from './vote-buttons.js'
import CopyButton from './copy-button.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 {formatDate} from '@/lib/format.js'
import {compareVersion} from '@/lib/directus.js' import {compareVersion} from '@/lib/directus.js'
function getVersionStatus(version, index, totalVersions, data) { function getStatusColor(isOutdated, index) {
// Logic to determine version status based on position and data if (isOutdated) {
// Find which version is the "main" (published) by checking if it matches current content return '#D32F2F'
// This would require the current item content to be passed
// For now, we assume the most recent is current unless it's been promoted
// Check if this is the initial version
if (index === totalVersions - 1) {
return 'initial' // First version
} }
// If there's a more recent version after this one, this is outdated if (index === 0) {
// unless this IS the main version (would need item content to determine) return '#1976D2'
if (index > 0) {
return 'outdated' // Older versions are outdated
} }
// Most recent version is current (being edited/proposed) return '#9E9E9E'
return 'current'
} }
function getStatusConfig(status) { function VersionItem({
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({
version, version,
index, index,
totalVersions,
accessToken, accessToken,
userId, userId,
countdownRef, countdownRef,
@@ -122,13 +40,11 @@ function VersionCard({
setVersionCompare, setVersionCompare,
onVoteResult onVoteResult
}) { }) {
const theme = useTheme()
const [versionStatus, setVersionStatus] = useState(null)
const [isOutdated, setIsOutdated] = useState(false) const [isOutdated, setIsOutdated] = useState(false)
const [expanded, setExpanded] = useState(false)
// Fetch real status from API
useEffect(() => { useEffect(() => {
async function fetchVersionStatus() { async function fetchStatus() {
try { try {
const comparisonData = await compareVersion({ const comparisonData = await compareVersion({
accessToken, accessToken,
@@ -140,41 +56,23 @@ function VersionCard({
}) })
if (comparisonData) { if (comparisonData) {
// Store outdated flag for vote disabling
setIsOutdated(comparisonData.outdated) setIsOutdated(comparisonData.outdated)
// Determine status based on API response
let status
if (comparisonData.outdated) {
status = 'outdated'
} else if (index === totalVersions - 1) {
status = 'initial'
} else {
status = 'current'
} }
setVersionStatus(status) } catch {
} setIsOutdated(false)
} catch (error) {
// Fallback to position-based status on error
setVersionStatus(getVersionStatus(version, index, totalVersions, null))
} }
} }
fetchVersionStatus() fetchStatus()
}, [version.id, index, totalVersions, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen]) }, [version.id, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen])
const status = versionStatus || getVersionStatus(version, index, totalVersions, null) const statusColor = getStatusColor(isOutdated, index)
const statusConfig = getStatusConfig(status)
const userDisplayName = version.user_created?.split('-')[0] || 'Système'
// Check if voting is disabled (after 3 days OR if outdated)
const createdAt = new Date(version.date_created) const createdAt = new Date(version.date_created)
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000)) const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
const isExpired = createdAt < threeDaysAgo const isVoteDisabled = createdAt < threeDaysAgo || isOutdated
const isVoteDisabled = isExpired || isOutdated
const handleCompareClick = async () => { const handleCompare = async () => {
const comparisonData = await compareVersion({ const comparisonData = await compareVersion({
accessToken, accessToken,
userId, userId,
@@ -190,167 +88,127 @@ 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 ( return (
<Card <Box
sx={{ sx={{
borderLeft: `4px solid ${statusConfig.color}`,
backgroundColor: statusConfig.bgColor,
mb: 2,
transition: 'all 0.2s ease-in-out',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: 3
}
}}
>
<CardContent>
<Box sx={{
display: 'flex', display: 'flex',
justifyContent: 'space-between', gap: 1.5,
alignItems: 'flex-start', py: 1.5,
mb: 2 borderBottom: '1px solid',
borderColor: 'divider',
'&:last-child': {borderBottom: 'none'}
}}
>
{/* Status indicator */}
<Box sx={{display: 'flex', 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={{minWidth: 0, flex: 1}}>
<Typography
variant='body2'
sx={{
fontWeight: 600,
color: statusColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}} }}
> >
<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} {version.name}
</Typography> </Typography>
<Typography variant='caption' color='text.secondary'> <Typography variant='caption' color='text.secondary'>
par @{userDisplayName} {formatDate(version.date_created, 'dd/MM/yy HH:mm')}
</Typography> </Typography>
</Box> </Box>
</Box>
<Box sx={{display: 'flex', gap: 1}}>
<Chip
label={statusConfig.label}
color={statusConfig.chipColor}
size='small'
variant='outlined'
/>
{isExpired && !isOutdated && (
<Chip
label='Vote fermé'
color='error'
size='small'
variant='outlined'
/>
)}
</Box>
</Box>
<Box sx={{mb: 2, '& > div': {fontSize: '0.875rem', fontStyle: 'italic'}}}> {/* Actions */}
<MarkdownRenderer <Box sx={{display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0}}>
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}
isOutdated={isOutdated}
size='small'
variant='text'
/>
<PrintButton
versionData={version}
isOutdated={isOutdated}
size='small'
variant='text'
/>
<VoteButtons <VoteButtons
hasCountsVisible hasCountsVisible
versionId={version.id} versionId={version.id}
isDisabled={isVoteDisabled} isDisabled={isVoteDisabled}
onVoteResult={onVoteResult} onVoteResult={onVoteResult}
/> />
<IconButton size='small' onClick={handleCompare} title='Comparer'>
<CompareArrowsIcon fontSize='small' />
</IconButton>
<IconButton size='small' onClick={() => setExpanded(!expanded)}>
{expanded ? <ExpandLessIcon fontSize='small' /> : <ExpandMoreIcon fontSize='small' />}
</IconButton>
</Box> </Box>
</Box> </Box>
</CardContent>
<Divider /> {/* Expanded content */}
<CardActions sx={{justifyContent: 'flex-end'}}> <Collapse in={expanded}>
<Button <Box sx={{mt: 1.5, display: 'flex', flexDirection: 'column', gap: 1}}>
size='small' {/* Preview */}
variant='outlined' {version.delta?.contenu && (
color='primary' <Typography
onClick={handleCompareClick} variant='caption'
color='text.secondary'
sx={{
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
fontStyle: 'italic'
}}
> >
Comparer {version.delta.contenu}
</Button> </Typography>
</CardActions> )}
</Card>
{/* 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({ export default function VersionTimeline({
collection,
data, data,
accessToken, accessToken,
userId, userId,
setError, setError,
setIsErrorAlertOpen setIsErrorAlertOpen,
onVoteSuccess
}) { }) {
const countdownRef = useRef() const countdownRef = useRef()
const [isOpenComparison, setIsOpenComparison] = useState(false) const [isOpenComparison, setIsOpenComparison] = useState(false)
@@ -359,53 +217,26 @@ export default function VersionTimeline({
const versionData = data.find(({id}) => id === versionCompare?.versionId) const versionData = data.find(({id}) => id === versionCompare?.versionId)
const handleVoteResult = result => { const handleVoteResult = (result, versionId) => {
setSnackbar({ setSnackbar({
open: true, open: true,
message: result.message, message: result.message,
severity: result.success ? 'success' : 'error' severity: result.success ? 'success' : 'error'
}) })
}
const handleCloseSnackbar = () => { if (result.success && onVoteSuccess && versionId) {
setSnackbar(prev => ({...prev, open: false})) onVoteSuccess(versionId)
}
} }
return ( return (
<> <>
<Box> <Box sx={{maxWidth: 500, mx: 'auto'}}>
<Typography variant='h5' textAlign='center' sx={{mb: 3, fontWeight: 'bold'}}>
Historique des versions - {collection}
</Typography>
<Timeline position='right'>
{data.map((version, index) => ( {data.map((version, index) => (
<TimelineItem key={version.id}> <VersionItem
<TimelineOppositeContent sx={{flex: 0.3, pr: 2}}> key={version.id}
<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} version={version}
index={index} index={index}
totalVersions={data.length}
accessToken={accessToken} accessToken={accessToken}
userId={userId} userId={userId}
countdownRef={countdownRef} countdownRef={countdownRef}
@@ -413,12 +244,9 @@ export default function VersionTimeline({
setIsErrorAlertOpen={setIsErrorAlertOpen} setIsErrorAlertOpen={setIsErrorAlertOpen}
setIsOpenComparison={setIsOpenComparison} setIsOpenComparison={setIsOpenComparison}
setVersionCompare={setVersionCompare} setVersionCompare={setVersionCompare}
onVoteResult={handleVoteResult} onVoteResult={result => handleVoteResult(result, version.id)}
/> />
</TimelineContent>
</TimelineItem>
))} ))}
</Timeline>
</Box> </Box>
{isOpenComparison && ( {isOpenComparison && (
@@ -440,9 +268,14 @@ export default function VersionTimeline({
open={snackbar.open} open={snackbar.open}
autoHideDuration={6000} autoHideDuration={6000}
anchorOrigin={{vertical: 'bottom', horizontal: 'center'}} anchorOrigin={{vertical: 'bottom', horizontal: 'center'}}
onClose={handleCloseSnackbar} onClose={() => setSnackbar(prev => ({...prev, open: false}))}
>
<Alert
variant='filled'
severity={snackbar.severity}
sx={{width: '100%'}}
onClose={() => setSnackbar(prev => ({...prev, open: false}))}
> >
<Alert variant='filled' severity={snackbar.severity} sx={{width: '100%'}} onClose={handleCloseSnackbar}>
{snackbar.message} {snackbar.message}
</Alert> </Alert>
</Snackbar> </Snackbar>
@@ -451,18 +284,17 @@ export default function VersionTimeline({
} }
VersionTimeline.propTypes = { VersionTimeline.propTypes = {
collection: PropTypes.oneOf(['titres', 'articles']).isRequired,
data: PropTypes.array.isRequired, data: PropTypes.array.isRequired,
accessToken: PropTypes.string.isRequired, accessToken: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
setError: PropTypes.func.isRequired, setError: PropTypes.func.isRequired,
setIsErrorAlertOpen: PropTypes.func.isRequired setIsErrorAlertOpen: PropTypes.func.isRequired,
onVoteSuccess: PropTypes.func
} }
VersionCard.propTypes = { VersionItem.propTypes = {
version: PropTypes.object.isRequired, version: PropTypes.object.isRequired,
index: PropTypes.number.isRequired, index: PropTypes.number.isRequired,
totalVersions: PropTypes.number.isRequired,
accessToken: PropTypes.string.isRequired, accessToken: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
countdownRef: PropTypes.object.isRequired, countdownRef: PropTypes.object.isRequired,
+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})
})
})
+41 -2
View File
@@ -141,9 +141,8 @@ export async function listVersions({
return versions return versions
} catch (error) { } catch (error) {
console.log('error', error)
if (error?.errors[0]?.message === 'Token expired.') { if (error) {
countdownRef.current.startCountdown() countdownRef.current.startCountdown()
} else { } else {
console.log(error?.errors[0]?.message) console.log(error?.errors[0]?.message)
@@ -370,3 +369,43 @@ export async function getUserVote({
throw error 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 return konstitisyon
} }
export function formatDate(date, formatStr = 'PP') { export function formatDate(date, formatStr = 'PP', {withTimezone = false} = {}) {
return format(date, formatStr, { const formatted = format(date, formatStr, {
locale: fr locale: fr
}) })
if (withTimezone) {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
return `${formatted} (${timezone})`
}
return formatted
} }
export function hasRestrictedChar(text) { 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}
}
}
+59
View File
@@ -0,0 +1,59 @@
import {NextResponse} from 'next/server'
import {createRateLimiter} from '@/lib/rate-limit.js'
// 5 inscriptions max par IP toutes les 15 minutes
const checkRegister = createRateLimiter({windowMs: 15 * 60 * 1000, max: 5})
// 10 tentatives de connexion max par IP toutes les 5 minutes
const checkSignin = createRateLimiter({windowMs: 5 * 60 * 1000, max: 10})
const limiters = {
'/api/auth/register': checkRegister,
'/api/auth/callback/credentials': checkSignin,
}
/**
* Extrait l'IP cliente depuis les headers HTTP.
* Priorité à X-Real-IP (Nginx), puis X-Forwarded-For.
*/
function getClientIp(request) {
const realIp = request.headers.get('x-real-ip')
if (realIp) {
return realIp.trim()
}
const forwarded = request.headers.get('x-forwarded-for')
if (forwarded) {
return forwarded.split(',')[0].trim()
}
return 'unknown'
}
export function middleware(request) {
const {pathname} = request.nextUrl
const check = limiters[pathname]
if (!check) {
return NextResponse.next()
}
const ip = getClientIp(request)
const result = check(`${ip}:${pathname}`)
if (result.success) {
return NextResponse.next()
}
return NextResponse.json(
{message: 'Trop de tentatives. Veuillez réessayer dans quelques minutes.'},
{
status: 429,
headers: {'Retry-After': String(result.retryAfter)},
}
)
}
export const config = {
matcher: ['/api/auth/register', '/api/auth/callback/credentials'],
}
-13
View File
@@ -1,13 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
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,
}
module.exports = nextConfig
+73
View File
@@ -0,0 +1,73 @@
/** @type {import('next').NextConfig} */
// 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 ?? ''
// 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}`.trim(),
`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 = {
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 nextConfig
+11944
View File
File diff suppressed because it is too large Load Diff
+17 -2
View File
@@ -3,7 +3,10 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "xo" "lint": "xo",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@directus/sdk": "^20.3.0", "@directus/sdk": "^20.3.0",
@@ -21,14 +24,16 @@
"jspdf": "^4.0.0", "jspdf": "^4.0.0",
"marked": "^17.0.1", "marked": "^17.0.1",
"next": "^16.1.0", "next": "^16.1.0",
"next-auth": "5.0.0-beta.25", "next-auth": "^5.0.0-beta.30",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-virtuoso": "^4.18.1", "react-virtuoso": "^4.18.1",
"use-debounce": "^10.0.5" "use-debounce": "^10.0.5"
}, },
"devDependencies": { "devDependencies": {
"@vitest/coverage-v8": "^4.1.4",
"eslint-config-xo-nextjs": "^6.0.0", "eslint-config-xo-nextjs": "^6.0.0",
"vitest": "^4.1.4",
"xo": "^0.58.0" "xo": "^0.58.0"
}, },
"xo": { "xo": {
@@ -49,6 +54,16 @@
"n/prefer-global/process": "off", "n/prefer-global/process": "off",
"comma-dangle": "off", "comma-dangle": "off",
"unicorn/prevent-abbreviations": "off" "unicorn/prevent-abbreviations": "off"
},
"overrides": [
{
"files": "lib/__tests__/**/*.js",
"envs": ["node", "es2020"],
"rules": {
"camelcase": ["error", {"properties": "never"}],
"capitalized-comments": "off"
} }
} }
]
}
} }
+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`
- [ ] **Tests extensions Directus** — mocks VersionsService
- [ ] **Refresh token explicite** — callback `jwt` dans NextAuth options
- [ ] **Pipeline CI** — GitHub Actions (lint + test + build)
- [ ] **Sentry** — tracking erreurs frontend + API routes
## Améliorations moyennes (P3)
- [ ] ISR page d'accueil (`revalidate`)
- [ ] Dockerisation frontend (`output: standalone`)
- [ ] Audit accessibilité WCAG 2.1
- [ ] Responsive mobile dashboard
- [ ] Lazy loading jsPDF + md-editor
- [ ] Migration NextAuth v5 stable
+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__/**'],
},
},
})
+29 -48
View File
@@ -2,18 +2,16 @@
# yarn lockfile v1 # yarn lockfile v1
"@auth/core@0.37.2": "@auth/core@0.41.0":
version "0.37.2" version "0.41.0"
resolved "https://registry.yarnpkg.com/@auth/core/-/core-0.37.2.tgz#0db8a94a076846bd88eb7f9273618513e2285cb2" resolved "https://registry.yarnpkg.com/@auth/core/-/core-0.41.0.tgz#6a57e18ab0dd0fc2606f9f0f7460a67190966161"
integrity sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw== integrity sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==
dependencies: dependencies:
"@panva/hkdf" "^1.2.1" "@panva/hkdf" "^1.2.1"
"@types/cookie" "0.6.0" jose "^6.0.6"
cookie "0.7.1" oauth4webapi "^3.3.0"
jose "^5.9.3" preact "10.24.3"
oauth4webapi "^3.0.0" preact-render-to-string "6.5.11"
preact "10.11.3"
preact-render-to-string "5.2.3"
"@babel/code-frame@^7.0.0": "@babel/code-frame@^7.0.0":
version "7.24.2" version "7.24.2"
@@ -633,11 +631,6 @@
dependencies: dependencies:
tslib "^2.8.0" tslib "^2.8.0"
"@types/cookie@0.6.0":
version "0.6.0"
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5"
integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==
"@types/debug@^4.0.0": "@types/debug@^4.0.0":
version "4.1.12" version "4.1.12"
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917"
@@ -1303,11 +1296,6 @@ convert-source-map@^1.5.0:
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f"
integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==
cookie@0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.1.tgz#2f73c42142d5d5cf71310a74fc4ae61670e5dbc9"
integrity sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==
core-js-compat@^3.34.0: core-js-compat@^3.34.0:
version "3.37.1" version "3.37.1"
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.37.1.tgz#c844310c7852f4bdf49b8d339730b97e17ff09ee" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.37.1.tgz#c844310c7852f4bdf49b8d339730b97e17ff09ee"
@@ -3083,10 +3071,10 @@ jackspeak@^2.3.5:
optionalDependencies: optionalDependencies:
"@pkgjs/parseargs" "^0.11.0" "@pkgjs/parseargs" "^0.11.0"
jose@^5.9.3: jose@^6.0.6:
version "5.10.0" version "6.1.3"
resolved "https://registry.yarnpkg.com/jose/-/jose-5.10.0.tgz#c37346a099d6467c401351a9a0c2161e0f52c4be" resolved "https://registry.yarnpkg.com/jose/-/jose-6.1.3.tgz#8453d7be88af7bb7d64a0481d6a35a0145ba3ea5"
integrity sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg== integrity sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0" version "4.0.0"
@@ -3809,12 +3797,12 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
next-auth@5.0.0-beta.25: next-auth@^5.0.0-beta.30:
version "5.0.0-beta.25" version "5.0.0-beta.30"
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-5.0.0-beta.25.tgz#3a9f9734e1d8fa5ced545360f1afc24862cb92d5" resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-5.0.0-beta.30.tgz#945af66d27d2e6defa34a4d96765df67fe4164cc"
integrity sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog== integrity sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==
dependencies: dependencies:
"@auth/core" "0.37.2" "@auth/core" "0.41.0"
next@^16.1.0: next@^16.1.0:
version "16.1.0" version "16.1.0"
@@ -3867,10 +3855,10 @@ nth-check@^2.0.0:
dependencies: dependencies:
boolbase "^1.0.0" boolbase "^1.0.0"
oauth4webapi@^3.0.0: oauth4webapi@^3.3.0:
version "3.6.0" version "3.8.3"
resolved "https://registry.yarnpkg.com/oauth4webapi/-/oauth4webapi-3.6.0.tgz#839a4520c59f82fdc84d129ce308562646aa3fbc" resolved "https://registry.yarnpkg.com/oauth4webapi/-/oauth4webapi-3.8.3.tgz#8a3e36b88a52db5e619907f031bff3770b2ed1a4"
integrity sha512-OwXPTXjKPOldTpAa19oksrX9TYHA0rt+VcUFTkJ7QKwgmevPpNm9Cn5vFZUtIo96FiU6AfPuUUGzoXqgOzibWg== integrity sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==
obj-props@^1.0.0: obj-props@^1.0.0:
version "1.4.0" version "1.4.0"
@@ -4185,17 +4173,15 @@ postcss@8.4.31:
picocolors "^1.0.0" picocolors "^1.0.0"
source-map-js "^1.0.2" source-map-js "^1.0.2"
preact-render-to-string@5.2.3: preact-render-to-string@6.5.11:
version "5.2.3" version "6.5.11"
resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz#23d17376182af720b1060d5a4099843c7fe92fe4" resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz#467e69908a453497bb93d4d1fc35fb749a78e027"
integrity sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA== integrity sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==
dependencies:
pretty-format "^3.8.0"
preact@10.11.3: preact@10.24.3:
version "10.11.3" version "10.24.3"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.11.3.tgz#8a7e4ba19d3992c488b0785afcc0f8aa13c78d19" resolved "https://registry.yarnpkg.com/preact/-/preact-10.24.3.tgz#086386bd47071e3b45410ef20844c21e23828f64"
integrity sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg== integrity sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==
prelude-ls@^1.2.1: prelude-ls@^1.2.1:
version "1.2.1" version "1.2.1"
@@ -4214,11 +4200,6 @@ prettier@^3.2.5:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368"
integrity sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A== integrity sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==
pretty-format@^3.8.0:
version "3.8.0"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-3.8.0.tgz#bfbed56d5e9a776645f4b1ff7aa1a3ac4fa3c385"
integrity sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==
prop-types@^15.6.2, prop-types@^15.8.1: prop-types@^15.6.2, prop-types@^15.8.1:
version "15.8.1" version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"