From 111d1e5c4bad1fac6dd14d63b7b2a88a7d54145f Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Mon, 21 Sep 2026 21:10:04 +0200 Subject: [PATCH] =?UTF-8?q?Afficher=20la=20photo=20du=20b=C3=A9n=C3=A9vole?= =?UTF-8?q?=20sur=20son=20=C3=A9cran=20et=20sur=20l'=C3=A9cran=20r=C3=A9f?= =?UTF-8?q?=C3=A9rent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photo transmise via le formulaire public (Volunteer.photo) : nouveau composant partagé VolunteerAvatar (photo si disponible, sinon repli sur le carré d'initiales existant, même gabarit dans les deux cas). - Écran bénévole (VolunteerHomeView) : photo dans le bandeau d'accueil, à côté du message de bienvenue. - Écran référent (MobileLeadPage) : photo devant le nom de chaque membre de l'équipe, à la place du carré d'initiales. GET .../volunteers/{id}/photo n'était accessible qu'à COORDINATION/ RÉFÉRENT ; ouvert aussi à l'intéressé lui-même (même garde que get_volunteer), sinon l'écran bénévole ne pouvait jamais charger sa propre photo. Vérifié : pytest (274 passés, dont le nouveau test d'accès à sa propre photo), ruff, tsc --noEmit, eslint, vite build, smoke — tous verts. Co-Authored-By: Claude Sonnet 5 --- backend/app/api/volunteers.py | 8 ++-- backend/tests/test_authz.py | 16 ++++++++ frontend/src/components/VolunteerAvatar.tsx | 43 +++++++++++++++++++++ frontend/src/pages/MobileLeadPage.tsx | 12 ++++-- frontend/src/pages/VolunteerHomeView.tsx | 20 ++++++++-- 5 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/VolunteerAvatar.tsx diff --git a/backend/app/api/volunteers.py b/backend/app/api/volunteers.py index d237ea9..0497acd 100644 --- a/backend/app/api/volunteers.py +++ b/backend/app/api/volunteers.py @@ -226,12 +226,14 @@ def get_volunteer_planning( def get_volunteer_photo( volunteer_id: uuid.UUID, edition: Edition = Depends(get_edition), - _guard: Account = Depends(require_staff), + account: Account = Depends(current_account_in_edition), session: Session = Depends(get_session), ) -> Response: """Photo transmise par le bénévole via le formulaire public - (`app/api/forms.py`) — réservée à COORDINATION/RÉFÉRENT, pas exposée à - l'écran bénévole.""" + (`app/api/forms.py`) — COORDINATION/RÉFÉRENT (ex. écran référent, en + face du nom de chaque membre de l'équipe) ou le bénévole lui-même (écran + bénévole).""" + _ensure_can_read(account, volunteer_id) volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id) if volunteer.photo is None: raise HTTPException(status_code=404, detail={"code": "error.not_found"}) diff --git a/backend/tests/test_authz.py b/backend/tests/test_authz.py index 3120805..c838908 100644 --- a/backend/tests/test_authz.py +++ b/backend/tests/test_authz.py @@ -67,6 +67,22 @@ def test_volunteer_scope(session, edition, make_account, as_account): ) +def test_volunteer_sees_only_own_photo(session, edition, make_account, as_account): + me = make_volunteer(session, edition, email="me@e.fr") + other = make_volunteer(session, edition, email="other@e.fr") + me.photo = b"fake-bytes" + me.photo_content_type = "image/png" + other.photo = b"fake-bytes" + other.photo_content_type = "image/png" + session.add_all([me, other]) + session.commit() + account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="me@e.fr") + client = as_account(account) + + assert client.get(_e(edition, f"/volunteers/{me.id}/photo")).status_code == 200 + assert client.get(_e(edition, f"/volunteers/{other.id}/photo")).status_code == 403 + + def test_volunteer_sees_only_own_assignments( session, edition, make_account, as_account ): diff --git a/frontend/src/components/VolunteerAvatar.tsx b/frontend/src/components/VolunteerAvatar.tsx new file mode 100644 index 0000000..b1816d8 --- /dev/null +++ b/frontend/src/components/VolunteerAvatar.tsx @@ -0,0 +1,43 @@ +import { useVolunteerPhotoUrl } from '@/api/hooks'; +import { cx } from '@/lib/cx'; + +/** + * Photo du bénévole (formulaire public, voir `app/api/forms.py`) si + * disponible, sinon repli sur un carré d'initiales — même gabarit dans les + * deux cas pour ne jamais faire sauter la mise en page. `className` fixe la + * taille (ex. `h-[38px] w-[38px] text-[13px]`). + */ +export function VolunteerAvatar({ + volunteerId, + hasPhoto, + initials, + className, +}: { + volunteerId: string; + hasPhoto: boolean; + initials: string; + className?: string; +}) { + const photoUrl = useVolunteerPhotoUrl(volunteerId, hasPhoto); + + if (photoUrl) { + return ( + + ); + } + + return ( + + {initials} + + ); +} diff --git a/frontend/src/pages/MobileLeadPage.tsx b/frontend/src/pages/MobileLeadPage.tsx index 89c9935..600b941 100644 --- a/frontend/src/pages/MobileLeadPage.tsx +++ b/frontend/src/pages/MobileLeadPage.tsx @@ -5,6 +5,7 @@ import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/ho import { useAuth } from '@/auth/auth-context'; import { ErrorNote, Loading } from '@/components/state'; import { NowLine } from '@/components/NowLine'; +import { VolunteerAvatar } from '@/components/VolunteerAvatar'; import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time'; import { useNowMinutes } from '@/lib/useNowMinutes'; import { t } from '@/i18n'; @@ -122,12 +123,14 @@ export function MobileLeadPage() { const volunteer = volunteersById.get(a.volunteer_id); return { id: a.id, + volunteerId: a.volunteer_id, name: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id, initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?', + hasPhoto: volunteer?.has_photo ?? false, phone: volunteer?.phone ?? '', present: presence[a.id] === 'present', absent: presence[a.id] === 'absent', @@ -155,9 +158,12 @@ export function MobileLeadPage() { className="border-[3px] border-ink bg-cream shadow-brutal-sm" >
- - {member.initials} - + {member.name} diff --git a/frontend/src/pages/VolunteerHomeView.tsx b/frontend/src/pages/VolunteerHomeView.tsx index 42e7277..5fe3bb6 100644 --- a/frontend/src/pages/VolunteerHomeView.tsx +++ b/frontend/src/pages/VolunteerHomeView.tsx @@ -3,6 +3,7 @@ import { Fragment, useMemo } from 'react'; import { useVolunteer, useVolunteerPlanning } from '@/api/hooks'; import { ErrorNote, Loading } from '@/components/state'; import { NowLine } from '@/components/NowLine'; +import { VolunteerAvatar } from '@/components/VolunteerAvatar'; import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time'; import { useNowMinutes } from '@/lib/useNowMinutes'; import { t } from '@/i18n'; @@ -39,6 +40,9 @@ export function VolunteerHomeView({ if (planning.error) return ; const firstName = meVolunteer.data?.first_name ?? fallbackName ?? ''; + const initials = meVolunteer.data + ? `${meVolunteer.data.first_name[0]}${meVolunteer.data.last_name[0]}` + : (firstName[0] ?? '?'); const totalHours = Math.round( @@ -55,9 +59,19 @@ export function VolunteerHomeView({ return (
-
{t('mobileVol.kicker')}
-
- {t('mobileVol.greeting', { name: firstName.toUpperCase() })} +
+ +
+
{t('mobileVol.kicker')}
+
+ {t('mobileVol.greeting', { name: firstName.toUpperCase() })} +
+