diff --git a/backend/src/routes/managers.ts b/backend/src/routes/managers.ts index ee038be..1f88080 100644 --- a/backend/src/routes/managers.ts +++ b/backend/src/routes/managers.ts @@ -8,6 +8,7 @@ import { MAX_PENDING_INVITATIONS, acceptInvitation, findPendingInvitationByToken, + hashInvitationToken, maskEmail, newInvitationToken, normalizeEmail, @@ -198,7 +199,15 @@ export const managerRoutes: FastifyPluginAsync = async (app) => { const params = tokenParamSchema.safeParse(request.params) if (!params.success) return reply.code(404).send({ error: 'invitation_invalid' }) const invitation = await findPendingInvitationByToken(app.prisma, params.data.token) - if (!invitation) return reply.code(404).send({ error: 'invitation_invalid' }) + if (!invitation) { + // Lien déjà servi ou expiré : dit tel quel, pour que la page explique quoi faire. Rien d'autre ne sort. + const known = await app.prisma.treeInvitation.findUnique({ + where: { tokenHash: hashInvitationToken(params.data.token) }, + select: { acceptedAt: true, revokedAt: true, expiresAt: true }, + }) + const error = known?.acceptedAt ? 'invitation_used' : known && !known.revokedAt && known.expiresAt <= new Date() ? 'invitation_expired' : 'invitation_invalid' + return reply.code(404).send({ error }) + } return reply.send({ treeName: invitation.tree.name, invitedBy: invitation.tree.owner.firstName || null, diff --git a/backend/test/co-gestion.db.test.ts b/backend/test/co-gestion.db.test.ts index 9726660..a73b778 100644 --- a/backend/test/co-gestion.db.test.ts +++ b/backend/test/co-gestion.db.test.ts @@ -109,6 +109,7 @@ describe.skipIf(!TEST_DB)('co-gestion, vraie base', () => { expect(accepted.status).toBe(200) expect(accepted.body).toEqual({ treeId, role: 'admin' }) expect((await call('POST', `/invitations/${token}/accept`, co.token)).status).toBe(404) + expect((await call('GET', `/invitations/${token}`)).body).toEqual({ error: 'invitation_used' }) expect((await prisma.treeInvitation.findUnique({ where: { id: invitation.id } }))?.acceptedUserId).toBe(co.id) // Co-gestionnaire : modifie l'arbre, voit la liste, mais n'invite pas, ne retire personne, ne supprime pas l'arbre @@ -157,6 +158,8 @@ describe.skipIf(!TEST_DB)('co-gestion, vraie base', () => { const late = await invite(email) await prisma.treeInvitation.update({ where: { id: late.invitation.id }, data: { expiresAt: new Date(Date.now() - 1000) } }) expect((await call('POST', `/invitations/${late.token}/accept`, user.token)).status).toBe(404) + expect((await call('GET', `/invitations/${late.token}`)).body).toEqual({ error: 'invitation_expired' }) + expect((await call('GET', `/invitations/${rotated.body.token}`)).body).toEqual({ error: 'invitation_invalid' }) }) it("inscription fermée : l'invitation ouvre un compte, à l'adresse invitée seulement", async () => { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d0524cc..b4b0059 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,6 +12,7 @@ import AdminPanel from './components/AdminPanel' import AccessGate from './components/AccessGate' import EntryChoice from './components/EntryChoice' import AccountDashboard from './components/AccountDashboard' +import InvitationScreen from './components/InvitationScreen.jsx' import AccountPanel from './components/AccountPanel' import TreeCreationWizard from './components/TreeCreationWizard' import PhotoStartPanel from './components/PhotoStartPanel.jsx' @@ -487,6 +488,14 @@ function readOidcReturn() { return { code: params.get('oidc'), error: params.get('oidc_error') } } const OIDC_RETURN = readOidcReturn() + +// Lien d'invitation à co-gérer un arbre (/invitation/, issue #145) : lu une fois au chargement, la page +// d'invitation passe avant tout le reste jusqu'à ce que l'arbre s'ouvre +function readInvitationToken() { + if (typeof window === 'undefined') return null + return window.location.pathname.match(/^\/invitation\/([A-Za-z0-9_-]{20,100})$/)?.[1] ?? null +} +const INVITATION_TOKEN = readInvitationToken() let oidcSignIn = null function App() { @@ -2963,6 +2972,25 @@ function App() { } + if (INVITATION_TOKEN) { + return ( +
+ window.location.assign(oidcStartUrl(`/invitation/${INVITATION_TOKEN}`)) : undefined} + onLogout={handleLogout} + onOpenApp={() => window.location.assign('/')} + /> +
+ ) + } + if (tree.bootState === 'loading') { return (
@@ -3348,7 +3376,12 @@ function App() { treeId={tree.treeContext.treeId} authToken={tree.treeContext.accessToken} isAdmin={userRole.isAdmin} + isOwner={userRole.isOwner} + currentUserId={auth.userAuth.user?.id || null} + oidcName={authOptions.oidc?.name || ''} onTreeDeleted={handleTreeDeleted} + onLeftTree={handleTreeDeleted} + onOpenShare={() => { setAdminPanelVisible(false); handleOpenContributionPanel() }} onGedcomImported={handleGedcomImported} onOpenPhotoStart={() => { setAdminPanelVisible(false); openPhotoStart() }} onClose={() => setAdminPanelVisible(false)} diff --git a/frontend/src/api/authApi.js b/frontend/src/api/authApi.js index 8418eac..b2db1d2 100644 --- a/frontend/src/api/authApi.js +++ b/frontend/src/api/authApi.js @@ -9,10 +9,11 @@ export async function loginUser(email, password) { return data } -export async function registerUser(firstName, email, password) { +// invitationToken : lien d'invitation à co-gérer un arbre, qui ouvre un compte même inscription fermée +export async function registerUser(firstName, email, password, invitationToken) { const data = await apiRequest('/auth/register', { method: 'POST', - body: JSON.stringify({ firstName, email, password }), + body: JSON.stringify({ firstName, email, password, ...(invitationToken ? { invitationToken } : {}) }), }) return data diff --git a/frontend/src/api/managersApi.js b/frontend/src/api/managersApi.js new file mode 100644 index 0000000..79fad1a --- /dev/null +++ b/frontend/src/api/managersApi.js @@ -0,0 +1,40 @@ +import { apiRequest } from './client' + +// Co-gestion d'un arbre (issue #145) : gestionnaires, invitations, et la page du lien d'invitation + +const auth = (token) => ({ Authorization: `Bearer ${token}` }) +const tree = (treeId) => `/trees/${encodeURIComponent(treeId)}` + +export function listManagers(treeId, token) { + return apiRequest(`${tree(treeId)}/managers`, { headers: auth(token) }) +} + +export function inviteManager(treeId, email, token) { + return apiRequest(`${tree(treeId)}/invitations`, { method: 'POST', headers: auth(token), body: JSON.stringify({ email }) }) +} + +export function renewInvitationLink(treeId, invitationId, token) { + return apiRequest(`${tree(treeId)}/invitations/${encodeURIComponent(invitationId)}/link`, { method: 'POST', headers: auth(token) }) +} + +export function revokeInvitation(treeId, invitationId, token) { + return apiRequest(`${tree(treeId)}/invitations/${encodeURIComponent(invitationId)}`, { method: 'DELETE', headers: auth(token) }) +} + +// Retirer un co-gestionnaire (le propriétaire), ou quitter la gestion (son propre identifiant) +export function removeManager(treeId, userId, token) { + return apiRequest(`${tree(treeId)}/managers/${encodeURIComponent(userId)}`, { method: 'DELETE', headers: auth(token) }) +} + +// Sous /api comme /api/arbre/:slug : nginx ne relaie à l'API que /api (préfixe retiré), /auth et /trees +export function fetchInvitation(invitationToken) { + return apiRequest(`/api/invitations/${encodeURIComponent(invitationToken)}`) +} + +export function acceptInvitation(invitationToken, token) { + return apiRequest(`/api/invitations/${encodeURIComponent(invitationToken)}/accept`, { method: 'POST', headers: auth(token) }) +} + +export function invitationUrl(invitationToken) { + return `${window.location.origin}/invitation/${invitationToken}` +} diff --git a/frontend/src/components/AccountDashboard.jsx b/frontend/src/components/AccountDashboard.jsx index 5f8eb32..44c781a 100644 --- a/frontend/src/components/AccountDashboard.jsx +++ b/frontend/src/components/AccountDashboard.jsx @@ -344,6 +344,19 @@ const AccountDashboard = ({
  • {t("Les arbres partagés avec vous restent à leurs propriétaires. Vous n'y aurez simplement plus accès.")}
  • + {/* Arbres gérés avec d'autres : ils partent aussi pour leurs co-gestionnaires (issue #145) */} + {(trees || []).filter((tree) => tree.role === 'owner' && tree.coManagers?.length > 0).map((tree) => ( +
    +
    + {t('« {name} » sera supprimé pour tout le monde', { name: tree.name })} + + {tree.coManagers.length > 1 + ? t('{names} le gèrent avec vous. Ils n’y auront plus accès, la famille non plus.', { names: tree.coManagers.join(', ') }) + : t('{name} le gère avec vous. Cette personne n’y aura plus accès, la famille non plus.', { name: tree.coManagers[0] })} + +
    +
    + ))}
    diff --git a/frontend/src/components/AccountScreens.css b/frontend/src/components/AccountScreens.css index 0cac3b9..249495f 100644 --- a/frontend/src/components/AccountScreens.css +++ b/frontend/src/components/AccountScreens.css @@ -65,3 +65,4 @@ .ap-item-avatar img { width: 100%; height: 100%; object-fit: cover; } .ap-item-name { flex: 1; min-width: 0; font: 400 16px/1.2 var(--pz-serif); color: var(--pz-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ap-item-name em { font-style: italic; font-weight: 300; } +.invite-doors { display: grid; gap: 10px; } diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index 9ced67d..91047b0 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -19,6 +19,8 @@ import { } from '../api/immichApi.js' import PzBusy from './PzBusy.jsx' import TreeVisitsSection from './TreeVisitsSection.jsx' +import TreeManagersSection from './TreeManagersSection.jsx' +import { removeManager } from '../api/managersApi.js' import { t } from '../i18n/index.js' import { readGedcomFile } from '../utils/gedcomText.js' import { readGedcomBundle, zipErrorMessage } from '../utils/gedcomBundle.js' @@ -39,8 +41,14 @@ const AdminPanel = ({ treeId, authToken, isAdmin = false, + // Propriétaire : supprime l'arbre et gère les co-gestionnaires. Un co-gestionnaire peut seulement quitter. + isOwner = false, + currentUserId = null, + oidcName = '', onClose, onTreeDeleted, + onLeftTree, + onOpenShare, onGedcomImported, onOpenPhotoStart, }) => { @@ -58,6 +66,7 @@ const AdminPanel = ({ const [savingPolicy, setSavingPolicy] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) + const [confirmLeave, setConfirmLeave] = useState(false) const [deleteConfirmName, setDeleteConfirmName] = useState('') const [gedcomFile, setGedcomFile] = useState(null) const [importingGedcom, setImportingGedcom] = useState(false) @@ -299,6 +308,22 @@ const AdminPanel = ({ } } + // Co-gestionnaire qui quitte : l'arbre disparaît de ses arbres, comme supprimé pour lui + const handleLeave = async () => { + if (!treeId || !authToken || saving || !currentUserId) return + setSaving(true) + setErrorMessage('') + try { + await removeManager(treeId, currentUserId, authToken) + onLeftTree?.(treeId) + onClose?.() + } catch { + setErrorMessage(t('Impossible de quitter la gestion de cet arbre.')) + } finally { + setSaving(false) + } + } + // ── Connexion Immich de l'arbre ────────────────────────────────────────────── const testImmichConnection = async () => { @@ -400,6 +425,7 @@ const AdminPanel = ({
    + @@ -414,6 +440,7 @@ const AdminPanel = ({ {successMessage &&
    {successMessage}
    } {section === 'visits' && } + {section === 'managers' && } {section === 'tree' && ( @@ -649,7 +676,28 @@ const AdminPanel = ({
    )} - {section === 'danger' && ( + {section === 'danger' && !isOwner && ( +
    +
    +
    + {t('Quitter la gestion de cet arbre')} +

    {t('Vous ne pourrez plus le modifier. Seul son propriétaire peut le supprimer.')}

    +
    + {confirmLeave ? ( +
    + + +
    + ) : ( + + )} +
    +
    + )} + + {section === 'danger' && isOwner && (
    diff --git a/frontend/src/components/InvitationScreen.jsx b/frontend/src/components/InvitationScreen.jsx new file mode 100644 index 0000000..5b8ed0a --- /dev/null +++ b/frontend/src/components/InvitationScreen.jsx @@ -0,0 +1,175 @@ +import { useEffect, useState } from 'react' +import { LogIn } from 'lucide-react' +import logoUrl from '../assets/parenthese-logo.svg?url' +import './AccountScreens.css' +import PzBusy from './PzBusy.jsx' +import { acceptInvitation, fetchInvitation } from '../api/managersApi.js' +import { getAccountErrorMessage } from '../utils/errorMessages.js' +import { t } from '../i18n/index.js' + +// Page d'un lien d'invitation à co-gérer un arbre (/invitation/, issue #145). Trois portes selon l'instance : +// le fournisseur OIDC, un compte existant, un compte à créer (même inscription fermée : l'invitation l'ouvre). +// Une fois accepté, l'arbre s'ouvre (le serveur l'a mis en dernier arbre ouvert). + +const GONE = { + invitation_used: t('Ce lien a déjà servi. Si c’était le vôtre, l’arbre est dans vos arbres.'), + invitation_expired: t('Ce lien a expiré. Demandez-en un nouveau à la personne qui vous a invité.'), + invitation_invalid: t('Ce lien d’invitation ne marche pas. Demandez-en un nouveau à la personne qui vous a invité.'), +} + +const InvitationScreen = ({ + invitationToken, + userToken = '', + userEmail = '', + loading = false, + errorMessage = '', + onLogin, + onRegister, + onOidcLogin, + onLogout, + onOpenApp, +}) => { + const [preview, setPreview] = useState({ loading: true, data: null, error: '' }) + const [mode, setMode] = useState('choose') + const [firstName, setFirstName] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [accepting, setAccepting] = useState(false) + const [acceptError, setAcceptError] = useState('') + + useEffect(() => { + let active = true + fetchInvitation(invitationToken) + .then((data) => { if (active) setPreview({ loading: false, data, error: '' }) }) + .catch((error) => { if (active) setPreview({ loading: false, data: null, error: error?.payload?.error || 'invitation_invalid' }) }) + return () => { active = false } + }, [invitationToken]) + + const handleAccept = async () => { + setAccepting(true) + setAcceptError('') + try { + await acceptInvitation(invitationToken, userToken) + onOpenApp() + } catch (error) { + setAcceptError(getAccountErrorMessage(error)) + setAccepting(false) + } + } + + const handleSubmit = async (event) => { + event.preventDefault() + const safeEmail = email.trim().toLowerCase() + // Créé par l'invitation, le compte est déjà co-gestionnaire ; connecté, il reste à accepter + if (mode === 'register') { + if (await onRegister(firstName.trim(), safeEmail, password, invitationToken)) onOpenApp() + } else { + await onLogin(safeEmail, password) + } + } + + const invitation = preview.data + let body + if (preview.loading) { + body =

    {t('Chargement…')}

    + } else if (!invitation) { + body = ( + <> +
    +

    {t('Invitation')}

    +

    {GONE[preview.error] || GONE.invitation_invalid}

    +
    + + + ) + } else { + const head = ( +
    +

    {t('Gérer « {name} »', { name: invitation.treeName })}

    +

    + {invitation.invitedBy + ? t('{name} vous invite à modifier cet arbre avec votre propre compte. L’invitation est pour {email}.', { name: invitation.invitedBy, email: invitation.emailHint }) + : t('Vous êtes invité à modifier cet arbre avec votre propre compte. L’invitation est pour {email}.', { email: invitation.emailHint })} +

    +
    + ) + if (userToken) { + body = ( + <> + {head} +

    {t('Vous êtes connecté avec {email}.', { email: userEmail })}

    + {acceptError &&
    {acceptError}
    } + + + + ) + } else if (mode === 'choose') { + body = ( + <> + {head} +
    + {invitation.oidc && onOidcLogin && ( + + )} + {invitation.oidc && invitation.passwordLogin &&
    {t('ou')}
    } + {invitation.passwordLogin && ( + <> + + + + )} +
    +

    {t('Le lien sert une fois et vaut 14 jours.')}

    + + ) + } else { + const isRegister = mode === 'register' + body = ( + <> + {head} + + {isRegister && ( +
    + + setFirstName(event.target.value)} required /> +
    + )} +
    + + setEmail(event.target.value)} required /> +

    {t('L’adresse à laquelle vous avez été invité.')}

    +
    +
    + + setPassword(event.target.value)} minLength={8} required /> + {isRegister &&

    {t('8 caractères minimum.')}

    } +
    + {errorMessage &&
    {errorMessage}
    } + + + + + ) + } + } + + return ( +
    +
    + Parenthèse +
    {body}
    +
    +
    + ) +} + +export default InvitationScreen diff --git a/frontend/src/components/SettingsPanels.css b/frontend/src/components/SettingsPanels.css index a338979..32beb61 100644 --- a/frontend/src/components/SettingsPanels.css +++ b/frontend/src/components/SettingsPanels.css @@ -56,6 +56,18 @@ .ts-row-actions { justify-content: flex-start; } } +/* ── Gestion : propriétaire et co-gestionnaires (issue #145) ── */ +.tm-row { padding: 12px 14px; } +.tm-who { display: flex; align-items: center; gap: 10px; min-width: 0; } +.tm-who .ts-row-text { min-width: 0; } +.tm-avatar { width: 34px; height: 34px; flex: none; border-radius: 50%; display: grid; place-items: center; background: var(--pz-a100); color: var(--pz-a700); font: 500 13px var(--pz-sans); } +.tm-avatar--pending { background: var(--pz-bg); color: var(--pz-muted); border: 1px dashed var(--pz-a300); } +.tm-mail { font: 400 13px var(--pz-sans); color: var(--pz-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tm-invite { display: flex; gap: 8px; } +.tm-invite .pz-input { flex: 1; min-width: 0; } +.contrib-invite-link input.contrib-link-url { border: 0; outline: none; min-height: 32px; } +@media (max-width: 480px) { .tm-invite { flex-direction: column; } } + /* ── Contributions : invitation ── */ .contrib-list-section { display: grid; gap: 18px; } .contrib-invite { display: grid; gap: 12px; padding: 16px; border-radius: var(--pz-r-m); background: var(--pz-a50); border: 1px solid var(--pz-a100); } @@ -259,3 +271,4 @@ .contrib-session-quick { justify-content: flex-end; } .contrib-field-label { width: 88px; } } +.tm-share-btn { justify-self: start; } diff --git a/frontend/src/components/TreeManagersSection.jsx b/frontend/src/components/TreeManagersSection.jsx new file mode 100644 index 0000000..bcc657f --- /dev/null +++ b/frontend/src/components/TreeManagersSection.jsx @@ -0,0 +1,221 @@ +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' + +// 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 initialOf(person) { + return (person.firstName || person.email || '?').trim().charAt(0).toUpperCase() +} + +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 } + 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

    {t('Chargement…')}

    + 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 diff --git a/frontend/src/components/coGestion.test.jsx b/frontend/src/components/coGestion.test.jsx new file mode 100644 index 0000000..986be6e --- /dev/null +++ b/frontend/src/components/coGestion.test.jsx @@ -0,0 +1,34 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import PersonCard from './PersonCard.jsx' +import AdminPanel from './AdminPanel.jsx' +import TreeDropdown from './navbar/TreeDropdown.jsx' + +// Co-gestion (issue #145) et « Modifier » de la fiche : ce que chaque rôle voit. Les droits eux-mêmes sont tenus +// par le serveur (backend/test/co-gestion.db.test.ts) ; ici, l'interface ne propose pas ce qui serait refusé. +const noop = () => {} +const person = { id: 'p1', firstName: 'Marie', lastName: 'Martin', isAlive: true } + +describe('fiche, « Modifier » d’un clic', () => { + it('proposé quand App le donne (administrateur, hors démo), hors mode modification', () => { + expect(renderToStaticMarkup()).toContain('pc-quick-edit') + }) + + it('absent pour la famille et la démo (App ne le donne pas), et déjà en modification', () => { + expect(renderToStaticMarkup()).not.toContain('pc-quick-edit') + expect(renderToStaticMarkup()).not.toContain('pc-quick-edit') + }) +}) + +describe('co-gestionnaire dans les menus', () => { + it('le menu des arbres nomme le rôle « Co-gestionnaire », plus « Admin »', () => { + const html = renderToStaticMarkup() + expect(html).toContain('Co-gestionnaire') + expect(html).not.toContain('>Admin<') + }) + + it('le panneau a un onglet Gestion', () => { + const html = renderToStaticMarkup() + expect(html).toContain('Gestion') + }) +}) diff --git a/frontend/src/components/navbar/TreeDropdown.jsx b/frontend/src/components/navbar/TreeDropdown.jsx index d31721c..86f2cdb 100644 --- a/frontend/src/components/navbar/TreeDropdown.jsx +++ b/frontend/src/components/navbar/TreeDropdown.jsx @@ -41,7 +41,7 @@ function TreeDropdown({ trees = [], activeTreeId, onSelect, onCreate }) {
    {tree.name}
    - {tree.role === 'owner' ? t('Propriétaire') : t('Admin')} + {tree.role === 'owner' ? t('Propriétaire') : t('Co-gestionnaire')}
    diff --git a/frontend/src/hooks/useAuth.js b/frontend/src/hooks/useAuth.js index a8b0d02..4865a5e 100644 --- a/frontend/src/hooks/useAuth.js +++ b/frontend/src/hooks/useAuth.js @@ -107,12 +107,12 @@ export function useAuth() { } } - const handleRegister = async (firstName, email, password) => { + const handleRegister = async (firstName, email, password, invitationToken) => { setAccountLoading(true) setAccountError('') try { - const result = await registerUser(firstName, email, password) + const result = await registerUser(firstName, email, password, invitationToken) const mergedUser = { ...(result.user || {}), firstName: firstName || result.user?.firstName || '', diff --git a/frontend/src/i18n/en/gestion.js b/frontend/src/i18n/en/gestion.js new file mode 100644 index 0000000..62773eb --- /dev/null +++ b/frontend/src/i18n/en/gestion.js @@ -0,0 +1,73 @@ +// Traductions anglaises, clé = texte français exact passé à t(). +// Co-gestion d'un arbre (issue #145) : onglet Gestion, page du lien d'invitation, suppression du compte +export default { + // Onglet Gestion (TreeManagersSection) + 'Gestion': 'Managers', + 'Co-gestionnaire': 'Co-manager', + 'Cette adresse gère déjà cet arbre.': 'This address already manages this tree.', + 'Cette adresse email ne semble pas valide.': 'This email address does not look valid.', + 'Trop d’invitations en attente. Annulez-en une avant d’en envoyer une autre.': 'Too many pending invitations. Cancel one before sending another.', + 'Impossible de charger les gestionnaires.': 'Could not load the managers.', + 'Impossible d’envoyer l’invitation.': 'Could not send the invitation.', + 'Impossible de créer le lien.': 'Could not create the link.', + 'Impossible d’annuler l’invitation.': 'Could not cancel the invitation.', + 'Impossible de retirer ce co-gestionnaire.': 'Could not remove this co-manager.', + '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.': + 'Co-managers edit the tree directly and review the family’s suggestions, like you. Only the owner invites or removes a co-manager, or deletes the tree.', + 'Retrait en cours': 'Removing', + 'Invité le {date}, en attente': 'Invited on {date}, pending', + 'Création du lien': 'Creating the link', + 'Copier le lien': 'Copy the link', + 'Annulation en cours': 'Cancelling', + 'Lien pour {email}': 'Link for {email}', + 'Lien d’invitation': 'Invitation link', + '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.': + 'Send it by text or email. It works once and lasts 14 days. “Copy the link” creates a new one, and the old one stops working.', + '{name} ne gère plus cet arbre.': '{name} no longer manages this tree.', + '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.': + 'This person may have seen the sharing password, which still lets them open the tree to read it. To cut that too, change it, then give the new one to the family.', + 'Changer le mot de passe de partage': 'Change the sharing password', + 'Inviter un co-gestionnaire': 'Invite a co-manager', + 'Son adresse email': 'Their email address', + 'prenom@exemple.fr': 'name@example.com', + 'Invitation en cours': 'Inviting', + 'Inviter': 'Invite', + '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.': + 'Parenthèse sends no email: you get a link to pass on. If they sign in with {name} and this address, they are added without the link.', + 'Parenthèse n’envoie pas de mail : vous recevez un lien à lui transmettre.': 'Parenthèse sends no email: you get a link to pass on.', + + // Avancé, pour un co-gestionnaire (AdminPanel) + 'Impossible de quitter la gestion de cet arbre.': 'Could not stop managing this tree.', + 'Quitter la gestion de cet arbre': 'Stop managing this tree', + 'Vous ne pourrez plus le modifier. Seul son propriétaire peut le supprimer.': 'You will no longer be able to edit it. Only its owner can delete it.', + 'Rester': 'Stay', + 'Quitter': 'Leave', + + // Page du lien d'invitation (InvitationScreen) + 'Ce lien a déjà servi. Si c’était le vôtre, l’arbre est dans vos arbres.': 'This link has already been used. If it was yours, the tree is in your trees.', + 'Ce lien a expiré. Demandez-en un nouveau à la personne qui vous a invité.': 'This link has expired. Ask the person who invited you for a new one.', + 'Ce lien d’invitation ne marche pas. Demandez-en un nouveau à la personne qui vous a invité.': 'This invitation link does not work. Ask the person who invited you for a new one.', + 'Invitation': 'Invitation', + 'Ouvrir Parenthèse': 'Open Parenthèse', + 'Gérer « {name} »': 'Manage “{name}”', + '{name} vous invite à modifier cet arbre avec votre propre compte. L’invitation est pour {email}.': '{name} invites you to edit this tree with your own account. The invitation is for {email}.', + 'Vous êtes invité à modifier cet arbre avec votre propre compte. L’invitation est pour {email}.': 'You are invited to edit this tree with your own account. The invitation is for {email}.', + 'Vous êtes connecté avec {email}.': 'You are signed in as {email}.', + 'Ouverture de l’arbre': 'Opening the tree', + 'Accepter et ouvrir l’arbre': 'Accept and open the tree', + 'Changer de compte': 'Use another account', + 'J’ai déjà un compte Parenthèse': 'I already have a Parenthèse account', + 'Le lien sert une fois et vaut 14 jours.': 'The link works once and lasts 14 days.', + 'L’adresse à laquelle vous avez été invité.': 'The address you were invited at.', + 'Créer mon compte et ouvrir l’arbre': 'Create my account and open the tree', + + // Suppression du compte (AccountDashboard) + '« {name} » sera supprimé pour tout le monde': '“{name}” will be deleted for everyone', + '{names} le gèrent avec vous. Ils n’y auront plus accès, la famille non plus.': '{names} manage it with you. They will lose access, and so will the family.', + '{name} le gère avec vous. Cette personne n’y aura plus accès, la famille non plus.': '{name} manages it with you. They will lose access, and so will the family.', + + // Erreurs (errorMessages.js) + "Cette invitation est pour une autre adresse. Utilisez l'adresse à laquelle vous avez été invité.": 'This invitation is for another address. Use the address you were invited at.', + "Ce lien d'invitation ne marche plus. Demandez-en un nouveau à la personne qui vous a invité.": 'This invitation link no longer works. Ask the person who invited you for a new one.', + 'Vous êtes déjà propriétaire de cet arbre.': 'You already own this tree.', +} diff --git a/frontend/src/i18n/en/index.js b/frontend/src/i18n/en/index.js index d0be806..89631f5 100644 --- a/frontend/src/i18n/en/index.js +++ b/frontend/src/i18n/en/index.js @@ -6,6 +6,7 @@ import contribution from './contribution.js' import compte from './compte.js' import photos from './photos.js' import souvenir from './souvenir.js' +import gestion from './gestion.js' export default { ...app, @@ -15,5 +16,6 @@ export default { ...compte, ...photos, ...souvenir, + ...gestion, } diff --git a/frontend/src/utils/errorMessages.js b/frontend/src/utils/errorMessages.js index a2d9963..65cd788 100644 --- a/frontend/src/utils/errorMessages.js +++ b/frontend/src/utils/errorMessages.js @@ -96,6 +96,19 @@ export function getAccountErrorMessage(error) { return t("Cette instance n'accepte plus de nouveaux comptes. Demandez à la personne qui l'héberge.") } + // Lien d'invitation à co-gérer un arbre (issue #145) + if (errorCode === 'invitation_email_mismatch') { + return t("Cette invitation est pour une autre adresse. Utilisez l'adresse à laquelle vous avez été invité.") + } + + if (errorCode === 'invitation_invalid' || errorCode === 'invitation_used' || errorCode === 'invitation_expired') { + return t("Ce lien d'invitation ne marche plus. Demandez-en un nouveau à la personne qui vous a invité.") + } + + if (errorCode === 'already_owner') { + return t('Vous êtes déjà propriétaire de cet arbre.') + } + if (errorCode === 'citation_text_required') { return t('Ajoutez le texte de la citation.') }