diff --git a/backend/core/settings.py b/backend/core/settings.py index 6574775..38e62bf 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -149,7 +149,7 @@ from datetime import timedelta SIMPLE_JWT = { 'ACCESS_TOKEN_LIFETIME': timedelta(days=1), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=36500), 'ROTATE_REFRESH_TOKENS': True, 'BLACKLIST_AFTER_ROTATION': True, 'AUTH_HEADER_TYPES': ('Bearer',), diff --git a/frontend/src/api/axios.js b/frontend/src/api/axios.js index bf79e91..af0d6c8 100644 --- a/frontend/src/api/axios.js +++ b/frontend/src/api/axios.js @@ -22,11 +22,12 @@ function expireSession() { } } -function refreshAccessToken() { +export function refreshAccessToken() { if (!refreshPromise) { const refresh = localStorage.getItem('refresh'); + if (!refresh) return Promise.reject(new Error('Aucun jeton de renouvellement.')); + refreshPromise = refreshClient.post('/auth/refresh/', { refresh }).then(({ data }) => { - // Une déconnexion ou une nouvelle connexion peut avoir eu lieu pendant la requête. if (localStorage.getItem('refresh') !== refresh) { throw new Error('La session a changé.'); } @@ -43,11 +44,19 @@ function refreshAccessToken() { return refreshPromise; } -api.interceptors.request.use((config) => { +api.interceptors.request.use(async (config) => { const publicRequest = ['/auth/login/', '/users/register/'].includes(config.url); - const token = localStorage.getItem('token'); config._publicRequest = publicRequest; - if (token && !publicRequest) config.headers.Authorization = `Bearer ${token}`; + if (publicRequest) { + delete config.headers.Authorization; + return config; + } + + let token = localStorage.getItem('token'); + if (!token && localStorage.getItem('refresh')) { + token = await refreshAccessToken(); + } + if (token) config.headers.Authorization = `Bearer ${token}`; else delete config.headers.Authorization; return config; }); @@ -65,7 +74,6 @@ api.interceptors.response.use( request._retry = true; const currentToken = localStorage.getItem('token'); const sentToken = request.headers.Authorization; - // Une autre requête a peut-être déjà renouvelé la session. const access = currentToken && sentToken !== `Bearer ${currentToken}` ? currentToken : await refreshAccessToken(); request.headers.Authorization = `Bearer ${access}`; diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index a007cee..8e12c69 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -14,26 +14,32 @@ export const AuthProvider = ({ children }) => { setIsAdmin(false); }; window.addEventListener('auth:logout', onLogout); + const checkAuth = async () => { - const token = localStorage.getItem('token'); - - if (token) { + if (localStorage.getItem('token') || localStorage.getItem('refresh')) { try { - // Verify token by fetching user profile - const response = await api.get('/users/me/'); - const userData = response.data; - - if (!cancelled && localStorage.getItem('token')) { + const { data: userData } = await api.get('/users/me/'); + if (!cancelled && (localStorage.getItem('token') || localStorage.getItem('refresh'))) { localStorage.setItem('user', JSON.stringify(userData)); setUser(userData); - setIsAdmin(userData.is_admin || false); + setIsAdmin(Boolean(userData.is_admin)); } } catch (error) { console.error('Failed to verify authentication', error); - + // Une panne réseau ne doit pas effacer la session conservée localement. + if (!cancelled && !error.response && localStorage.getItem('refresh')) { + try { + const cachedUser = JSON.parse(localStorage.getItem('user')); + if (cachedUser) { + setUser(cachedUser); + setIsAdmin(Boolean(cachedUser.is_admin)); + } + } catch { + // Le profil en cache est illisible : une prochaine requête pourra le récupérer. + } + } } } - if (!cancelled) setLoading(false); }; @@ -45,52 +51,31 @@ export const AuthProvider = ({ children }) => { }, []); const login = async (username, password) => { - const response = await api.post('/auth/login/', { - username, - password, - }); - + const response = await api.post('/auth/login/', { username, password }); const { access, refresh } = response.data; - localStorage.setItem('token', access); localStorage.setItem('refresh', refresh); - // Fetch user info after login - const userResponse = await api.get('/users/me/'); - const userData = userResponse.data; - + const { data: userData } = await api.get('/users/me/'); localStorage.setItem('user', JSON.stringify(userData)); - setUser(userData); - setIsAdmin(userData.is_admin || false); - + setIsAdmin(Boolean(userData.is_admin)); return userData; }; const register = async (data) => { const response = await api.post('/users/register/', data); - return response.data; }; const logout = () => { clearSession(); - setUser(null); setIsAdmin(false); }; return ( - + {children} ); diff --git a/frontend/src/pages/NotesPage.jsx b/frontend/src/pages/NotesPage.jsx index e391074..0fd8349 100644 --- a/frontend/src/pages/NotesPage.jsx +++ b/frontend/src/pages/NotesPage.jsx @@ -1,710 +1,1172 @@ import { useEffect, useRef, useState } from 'react'; import { + Check, + ChevronDown, + ClipboardList, + Globe2, + Info, + LockKeyhole, + MessageCircleQuestionMark, + Pencil, + Plus, + RotateCcw, + Search, + StickyNote, + Trash2, + Users, + } from 'lucide-react'; import { notesApi } from '../api/notes'; import MainCard from '@/components/MainCard'; +import LoadingCard from '@/components/LoadingCard'; + import ConfirmDialog from '@/components/ConfirmDialog'; import { + Alert, + AlertDescription, + } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; + import { Button } from '@/components/ui/button'; import { + Card, + CardContent, + } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; + import { Label } from '@/components/ui/label'; import { + NativeSelect, + NativeSelectOption, + } from '@/components/ui/native-select'; import { + Popover, + PopoverContent, + PopoverTrigger, + } from '@/components/ui/popover'; import { Switch } from '@/components/ui/switch'; + import { Textarea } from '@/components/ui/textarea'; - const kinds = { + task: { + label: 'Tâche', + icon: ClipboardList, + color: + 'text-blue-700 bg-blue-50 dark:text-blue-300 dark:bg-blue-950/40', + }, info: { + label: 'Information', + icon: Info, + color: + 'text-emerald-700 bg-emerald-50 dark:text-emerald-300 dark:bg-emerald-950/40', + }, memo: { + label: 'Mémo', + icon: StickyNote, + color: + 'text-amber-700 bg-amber-50 dark:text-amber-300 dark:bg-amber-950/40', + }, sugg: { + label: 'Suggestion', + icon: MessageCircleQuestionMark, + color: + 'text-violet-700 bg-violet-50 dark:text-violet-300 dark:bg-violet-950/40', + }, + }; - const emptyForm = () => ({ + title: '', + content: '', + kind: 'memo', + recipient_ids: [], + is_public: true, + }); - const userLabel = (user) => + user + ? `${user.first_name} ${user.last_name}`.trim() + || user.username + : 'Compte supprimé'; - const dateLabel = (value) => + new Date(value).toLocaleString( + 'fr-FR', + { + dateStyle: 'medium', + timeStyle: 'short', + }, + ); - export default function NotesPage() { + const [result, setResult] = useState({ + count: 0, + results: [], + next: null, + previous: null, + }); const [ + recipients, + setRecipients, + ] = useState([]); const [ + recipientsError, + setRecipientsError, + ] = useState(''); const [ + filters, + setFilters, + ] = useState({ + kind: '', + scope: 'all', + state: 'all', + search: '', + }); const [ + searchText, + setSearchText, + ] = useState(''); const [page, setPage] = useState(1); const [ + revision, + setRevision, + ] = useState(0); const [ - loading, - setLoading, + + isLoading, + + setIsLoading, + ] = useState(true); const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); const [ + dialogOpen, + setDialogOpen, + ] = useState(false); const [ + recipientPopoverOpen, + setRecipientPopoverOpen, + ] = useState(false); const [ + editing, + setEditing, + ] = useState(null); const [ + form, + setForm, + ] = useState(emptyForm); const [ + formError, + setFormError, + ] = useState(''); const [ + saving, + setSaving, + ] = useState(false); const [ + busyId, + setBusyId, + ] = useState(null); const mutationInFlight = useRef(false); - /* + * ============================ + * Chargement des publications + * ============================ + */ useEffect(() => { + const controller = + new AbortController(); notesApi + .list( + { + ...filters, + page, + }, + { + signal: controller.signal, + }, + ) + .then((data) => { + if (!controller.signal.aborted) { + setResult(data); + setError(''); + } + }) + .catch((requestError) => { + if ( + controller.signal.aborted + ) { + return; + } if ( + requestError.response?.status === + 404 + && page > 1 + ) { + setPage(1); + } else { + setError( + requestError.message, + ); + } + }) + .finally(() => { + if ( + !controller.signal.aborted + ) { - setLoading(false); + + setIsLoading(false); + } + }); return () => + controller.abort(); + }, [ + filters, + page, + revision, + ]); - /* + * ============================ + * Annuaire + * ============================ + */ useEffect(() => { + const controller = + new AbortController(); notesApi + .recipients({ + signal: controller.signal, + }) + .then((data) => { + if (!controller.signal.aborted) { + setRecipients(data); + setRecipientsError(''); + } + }) + .catch((requestError) => { + if ( + !controller.signal.aborted + ) { + setRecipientsError( + requestError.message, + ); + } + }); return () => + controller.abort(); + }, [revision]); - const reload = () => { - setLoading(true); + + setIsLoading(true); setRevision( + (value) => value + 1, + ); + }; - const changeFilters = ( + changes, + ) => { - setLoading(true); + + setIsLoading(true); + setPage(1); setFilters( + (current) => ({ + ...current, + ...changes, + }), + ); + }; - /* + * ============================ + * Éditeur + * ============================ + */ const openEditor = ( + note = null, + ) => { + setEditing(note); setForm( + note + ? { + title: note.title, + content: note.content, + kind: note.kind, recipient_ids: + note.recipients?.map( + (person) => + person.id, + ) ?? [], is_public: + note.is_public !== false, + } + : emptyForm(), + ); setFormError(''); + setRecipientPopoverOpen(false); + setDialogOpen(true); + }; - /* + * ============================ + * Destinataires + * ============================ + */ const allRecipientIds = - recipients.map( - (person) => person.id, - ); + recipients.map( + + (person) => person.id, + + ); const allRecipientsSelected = + recipients.length > 0 + && recipients.every( + (person) => + form.recipient_ids.includes( + person.id, + ), + ); - const toggleRecipient = ( + id, + ) => { + setForm( + (current) => { + const selected = + current.recipient_ids.includes( + id, + ); return { + ...current, recipient_ids: selected + ? current.recipient_ids.filter( + (recipientId) => + recipientId !== id, + ) + : [ + ...current.recipient_ids, + id, + ], + }; + }, + ); + }; - const toggleAllRecipients = () => { + /* + * "Tout le monde" n'est disponible + * que pour une publication publique. + */ + if (!form.is_public) { + return; + } setForm( + (current) => ({ + ...current, recipient_ids: + allRecipientsSelected + ? [] + : allRecipientIds, + }), + ); + }; - const recipientButtonLabel = () => { + const count = + form.recipient_ids.length; /* + * On n'affiche "Tout le monde" + * que pour une publication publique. + * + * En privé, même si tous les comptes + * ont été sélectionnés manuellement, + * on affiche le nombre de personnes. + */ + if ( + form.is_public + && count > 0 + && allRecipientsSelected + ) { + return 'Tout le monde'; + } if (count === 0) { + return 'Aucun destinataire'; + } if (count === 1) { + const person = + recipients.find( + (recipient) => + recipient.id + === form.recipient_ids[0], + ); return person + ? userLabel(person) + : '1 personne'; + } return `${count} personnes`; + }; - /* + * ============================ + * Public / privé + * ============================ + */ const changeVisibility = ( + checked, + ) => { + setForm( + (current) => { + /* + * Si "Tout le monde" était sélectionné + * en mode public et que l'on passe + * en privé, on vide les destinataires. + * + * Cela oblige à choisir explicitement + * les personnes autorisées à voir + * la publication privée. + */ + const everyoneWasSelected = + recipients.length > 0 + && recipients.every( + (person) => + current.recipient_ids.includes( + person.id, + ), + ); return { + ...current, is_public: checked, recipient_ids: + !checked + && everyoneWasSelected + ? [] + : current.recipient_ids, + }; + }, + ); setFormError(''); + }; - /* + * ============================ + * Enregistrement + * ============================ + */ const save = async (event) => { + event.preventDefault(); if ( + mutationInFlight.current + ) { + return; + } if ( + !form.is_public + && form.recipient_ids.length === 0 + ) { + setFormError( + 'Une publication privée doit avoir au moins un destinataire.', + ); return; + } mutationInFlight.current = true; setSaving(true); + setFormError(''); const payload = { + ...form, title: + form.title.trim(), content: + form.content.trim(), + }; try { + if (editing) { + await notesApi.update( + editing.id, + payload, + ); + } else { + await notesApi.create( + payload, + ); + } setDialogOpen(false); setNotice( + editing + ? 'Publication modifiée.' + : 'Publication créée.', + ); setPage(1); + reload(); + } catch (requestError) { + setFormError( + requestError.message, + ); + } finally { + mutationInFlight.current = + false; setSaving(false); + } + }; - /* + * ============================ + * Suppression / complétion + * ============================ + */ const mutateNote = async ( + note, + operation, + ) => { + if ( + mutationInFlight.current + ) { + return; + } mutationInFlight.current = true; setBusyId(note.id); + setError(''); + setNotice(''); try { + if ( + operation === 'delete' + ) { + await notesApi.remove( + note.id, + ); setNotice( + 'Publication supprimée.', + ); setPage(1); + } else { + await notesApi.complete( + note.id, + !note.is_done, + ); setNotice( + note.is_done + ? 'Tâche remise à faire.' + : 'Tâche terminée.', + ); + } reload(); + } catch (requestError) { + setError( + requestError.message, + ); + } finally { + mutationInFlight.current = + false; setBusyId(null); + } + }; + if (isLoading) { + return ( + + ); + } return ( + + openEditor() + } + > + + Nouvelle publication + + + } + > {error && ( - - - {error} - - - )} + + + + + {error} + + + + + + )} {notice && ( +

+ {notice} +

+ )} - {/* ===================== */} + {/* FILTRES */} + {/* ===================== */}
@@ -712,191 +1174,283 @@ export default function NotesPage() {
+ changeFilters({ + scope: + event.target.value, + }) + } + > + + Toute l’équipe + + Pour moi + + Mes publications + +
-
+ changeFilters({ + kind: + event.target.value, state: 'all', + }) + } + > + Tous les types + {Object.entries( + kinds, + ).map( + ([key, kind]) => ( + + {kind.label} + + ), + )}
-
+ changeFilters({ + state: + event.target.value, + }) + } + > + Tous les états + + À faire + + Terminées +
-
{ + event.preventDefault(); changeFilters({ + search: + searchText.trim(), + }); + }} + >
+ setSearchText( + event.target.value, + ) + } + placeholder="Titre ou contenu…" + />
-
-
- {/* ===================== */} + {/* PUBLICATIONS */} + {/* ===================== */} - {loading ? ( + {error ? null -

- Chargement des publications… -

- - ) : error ? null : result.results.length === 0 ? (
@@ -904,57 +1458,75 @@ export default function NotesPage() {

+ Aucune publication à afficher +

+ Créez une tâche, + partagez une information + ou ajustez les filtres. +

) : ( + <> -

- {result.count}{' '} - publication - {result.count > 1 - ? 's' - : ''} -

- -
{result.results.map( + (note) => { + const kind = + kinds[note.kind]; const Icon = + kind.icon; return ( + @@ -964,70 +1536,95 @@ export default function NotesPage() {
+ {kind.label} + - {note.kind + === 'task' && ( + {note.is_done + ? 'Terminée' + : 'À faire'} + )} - {!note.is_public && ( + + Privé + )}
- {/* Titre */}

- {note.title} -

+ className={`break-words text-base font-semibold ${ + + note.is_done + + ? 'text-muted-foreground' + + : '' + + }`} + + > + + {note.title} + + {/* Contenu */} {note.content && (

+ {note.content} +

)} - {/* Destinataires */}
@@ -1037,58 +1634,81 @@ export default function NotesPage() { {note.recipients?.length + ? `Pour ${note.recipients + .map( + userLabel, + ) + .join(', ')}` + : 'Aucun destinataire'}
- {/* Métadonnées */}

+ Par{' '} + {userLabel( + note.author, + )} + {' · '} -

+ dateTime={ + + note.created_at + + } + + > + + {dateLabel( + + note.created_at, + + )} + + + +

{note.completed_at && (

+ Terminée le{' '} + {dateLabel( + note.completed_at, + )} +

)}
- {/* Actions */} {(note.can_complete + || note.can_edit) && (
@@ -1096,87 +1716,145 @@ export default function NotesPage() { {note.can_complete && ( )} - {note.can_edit && ( + <> - + mutateNote( + note, + 'delete', + ) + } + > + )}
@@ -1186,56 +1864,85 @@ export default function NotesPage() {
+ ); + }, + )}
- {(result.next + || result.previous) && (
- + Page {page} + -
@@ -1243,145 +1950,211 @@ export default function NotesPage() { )} + )}
- {/* ========================= */} + {/* DIALOGUE */} + {/* ========================= */} { + if (!saving) { + setDialogOpen(open); + } + }} + >
+ {editing + ? 'Modifier la publication' + : 'Nouvelle publication'} + + Sélectionnez les personnes + concernées et choisissez la + visibilité de la publication. + - {formError && ( + + {formError} + + )} - {recipientsError && ( + + Annuaire indisponible :{' '} + {recipientsError} + + )} - {/* Type */}
+ setForm({ + ...form, kind: + event.target.value, + }) + } + > {Object.entries( + kinds, + ).map( + ([key, kind]) => ( + {kind.label} + ), + )}
- {/* Destinataires */}
@@ -1435,7 +2227,6 @@ export default function NotesPage() { )} - {/* Utilisateurs */}
@@ -1443,56 +2234,89 @@ export default function NotesPage() { {recipients.length === 0 ? (

+ Aucun utilisateur disponible +

) : ( recipients.map( + (person) => { + const checked = + form.recipient_ids.includes( + person.id, + ); return ( ); + }, + ) )} @@ -1515,18 +2341,22 @@ export default function NotesPage() { - {!form.is_public && ( +

+ En privé, choisissez + explicitement les personnes + autorisées à voir la publication. +

+ )}
- {/* Public / privé */}
@@ -1534,141 +2364,213 @@ export default function NotesPage() {
-

{form.is_public + ? "Visible par toute l'équipe" + : form.recipient_ids.length + ? 'Visible uniquement par vous et les destinataires' + : 'Sélectionnez au moins un destinataire'}

-
- {/* Titre */}
+ setForm({ + ...form, title: + event.target.value, + }) + } + placeholder="Ex. Préparer la commande de demain" + />
- {/* Contenu */}