import { useEffect, useState } from 'react' import { inviteManager, invitationUrl, listManagers, removeManager, renewInvitationLink, revokeInvitation } from '../api/managersApi.js' import PzBusy from './PzBusy.jsx' import { dateLocale, t } from '../i18n/index.js' import { initialOf } from '../utils/initial.js' import Skeleton from './Skeleton.jsx' import { toOpaqueTreeId, trackAppEvent } from '../utils/analytics.js' // Onglet Gestion des paramètres de l'arbre (issue #145). Le propriétaire invite des co-gestionnaires par leur // adresse : Parenthèse n'envoie pas de mail, il donne un lien à transmettre. Les co-gestionnaires voient la liste. const ERRORS = { already_manager: t('Cette adresse gère déjà cet arbre.'), invalid_email: t('Cette adresse email ne semble pas valide.'), too_many_invitations: t('Trop d’invitations en attente. Annulez-en une avant d’en envoyer une autre.'), } function personInitial(person) { return initialOf(person.firstName || person.email) } const TreeManagersSection = ({ treeId, authToken, currentUserId = null, oidcName = '', onOpenShare }) => { const [state, setState] = useState({ loading: true, error: '', data: null }) const [email, setEmail] = useState('') const [busy, setBusy] = useState('') const [message, setMessage] = useState({ error: '', success: '' }) // Lien tout juste créé, montré en clair si le presse-papiers est refusé const [link, setLink] = useState(null) const [confirmRemove, setConfirmRemove] = useState(null) // Co-gestionnaire tout juste retiré : il a pu lire le mot de passe de partage, on propose de le changer const [removedName, setRemovedName] = useState('') const load = () => listManagers(treeId, authToken) .then((data) => setState({ loading: false, error: '', data })) .catch(() => setState({ loading: false, error: t('Impossible de charger les gestionnaires.'), data: null })) useEffect(() => { setState({ loading: true, error: '', data: null }) load() }, [treeId, authToken]) // eslint-disable-line react-hooks/exhaustive-deps const offerLink = async (token, forEmail) => { const url = invitationUrl(token) let copied try { await navigator.clipboard.writeText(url); copied = true } catch { copied = false } // Le lien créé compte comme un partage même si le presse-papiers est refusé (il est alors montré en clair) trackAppEvent('share_copied', { what: 'manager_invite', copied, tree_id: toOpaqueTreeId(treeId) }) setLink({ url, email: forEmail, copied }) } const handleInvite = async (event) => { event.preventDefault() if (!email.trim() || busy) return setBusy('invite') setMessage({ error: '', success: '' }) try { const { token, invitation } = await inviteManager(treeId, email.trim(), authToken) setEmail('') await offerLink(token, invitation.email) await load() } catch (error) { setMessage({ error: ERRORS[error?.payload?.error] || t('Impossible d’envoyer l’invitation.'), success: '' }) } finally { setBusy('') } } const handleCopy = async (invitation) => { setBusy(`link-${invitation.id}`) setMessage({ error: '', success: '' }) try { const { token } = await renewInvitationLink(treeId, invitation.id, authToken) await offerLink(token, invitation.email) } catch { setMessage({ error: t('Impossible de créer le lien.'), success: '' }) } finally { setBusy('') } } const handleRevoke = async (invitation) => { setBusy(`revoke-${invitation.id}`) try { await revokeInvitation(treeId, invitation.id, authToken) if (link?.email === invitation.email) setLink(null) await load() } catch { setMessage({ error: t('Impossible d’annuler l’invitation.'), success: '' }) } finally { setBusy('') } } const handleRemove = async (manager) => { setBusy(`remove-${manager.userId}`) try { await removeManager(treeId, manager.userId, authToken) setConfirmRemove(null) setMessage({ error: '', success: '' }) setRemovedName(manager.firstName || manager.email) await load() } catch { setMessage({ error: t('Impossible de retirer ce co-gestionnaire.'), success: '' }) } finally { setBusy('') } } if (state.loading) return if (state.error) return
{state.error}
const { canManage, managers, invitations } = state.data return (

{t('Les co-gestionnaires modifient l’arbre directement et valident les propositions de la famille, comme vous. Seul le propriétaire invite, retire un co-gestionnaire ou supprime l’arbre.')}

{managers.map((manager) => { const isYou = manager.userId === currentUserId return (
{isYou ? t('Vous') : (manager.firstName || manager.email)} {manager.email}
{manager.role === 'owner' ? t('Propriétaire') : t('Co-gestionnaire')} {canManage && manager.role === 'admin' && (confirmRemove === manager.userId ? ( <> ) : ( ))}
) })} {invitations.map((invitation) => (
{invitation.email} {t('Invité le {date}, en attente', { date: new Date(invitation.createdAt).toLocaleDateString(dateLocale, { day: 'numeric', month: 'short' }) })}
{canManage && (
)}
))} {link && (
{t('Lien pour {email}', { email: link.email })} {link.copied && {t('Lien copié')}}
event.target.select()} />

{t('Envoyez-le par message ou par mail. Il sert une fois et vaut 14 jours. « Copier le lien » en crée un nouveau, l’ancien ne marche plus.')}

)} {removedName && (
{t('{name} ne gère plus cet arbre.', { name: removedName })}

{t('Cette personne a pu voir le mot de passe de partage : avec lui, elle peut encore ouvrir l’arbre en lecture. Pour lui couper aussi la lecture, changez-le, puis donnez le nouveau à la famille.')}

{onOpenShare && ( )}
)} {message.error &&
{message.error}
} {message.success &&
{message.success}
} {canManage && (
{t('Inviter un co-gestionnaire')}
setEmail(event.target.value)} />

{oidcName ? t('Parenthèse n’envoie pas de mail : vous recevez un lien à lui transmettre. S’il se connecte avec {name} et cette adresse, il est ajouté sans le lien.', { name: oidcName }) : t('Parenthèse n’envoie pas de mail : vous recevez un lien à lui transmettre.')}

)}
) } export default TreeManagersSection