mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-09 15:50:58 +00:00
Co-gestion (#145), écrans : onglet Gestion, page du lien d'invitation, Quitter pour le co-gestionnaire, avertissement à la suppression du compte, Immich caché
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ac9c33d43f
commit
07d69896ae
17 files changed
+686
-7
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/<jeton>, 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 (
|
||||
<div className="app">
|
||||
<InvitationScreen
|
||||
invitationToken={INVITATION_TOKEN}
|
||||
userToken={auth.userAuth.token || ''}
|
||||
userEmail={auth.userAuth.user?.email || ''}
|
||||
loading={auth.accountLoading}
|
||||
errorMessage={auth.accountError}
|
||||
onLogin={auth.handleLogin}
|
||||
onRegister={auth.handleRegister}
|
||||
onOidcLogin={authOptions.oidc ? () => window.location.assign(oidcStartUrl(`/invitation/${INVITATION_TOKEN}`)) : undefined}
|
||||
onLogout={handleLogout}
|
||||
onOpenApp={() => window.location.assign('/')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (tree.bootState === 'loading') {
|
||||
return (
|
||||
<div className="app app-loading" />
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -344,6 +344,19 @@ const AccountDashboard = ({
|
||||
</li>
|
||||
<li>{t("Les arbres partagés avec vous restent à leurs propriétaires. Vous n'y aurez simplement plus accès.")}</li>
|
||||
</ul>
|
||||
{/* 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) => (
|
||||
<div key={tree.id} className="ts-danger account-comanaged">
|
||||
<div className="ts-row-text">
|
||||
<strong>{t('« {name} » sera supprimé pour tout le monde', { name: tree.name })}</strong>
|
||||
<span className="pz-small">
|
||||
{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] })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<form className="pz-auth-form" onSubmit={handleDeleteSubmit}>
|
||||
<div className="pz-field">
|
||||
|
||||
@@ -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; }
|
||||
@@ -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 = ({
|
||||
|
||||
<div className="pz-tabs" role="tablist">
|
||||
<button type="button" role="tab" aria-selected={section === 'tree'} className="pz-tab" onClick={() => setSection('tree')}>{t('Votre arbre')}</button>
|
||||
<button type="button" role="tab" aria-selected={section === 'managers'} className="pz-tab" onClick={() => setSection('managers')}>{t('Gestion')}</button>
|
||||
<button type="button" role="tab" aria-selected={section === 'visits'} className="pz-tab" onClick={() => setSection('visits')}>{t('Visites')}</button>
|
||||
<button type="button" role="tab" aria-selected={section === 'data'} className="pz-tab" onClick={() => setSection('data')}>{t('Données')}</button>
|
||||
<button type="button" role="tab" aria-selected={section === 'danger'} className="pz-tab" onClick={() => setSection('danger')}>{t('Avancé')}</button>
|
||||
@@ -414,6 +440,7 @@ const AdminPanel = ({
|
||||
{successMessage && <div className="pz-success" role="status">{successMessage}</div>}
|
||||
|
||||
{section === 'visits' && <TreeVisitsSection treeId={treeId} authToken={authToken} />}
|
||||
{section === 'managers' && <TreeManagersSection treeId={treeId} authToken={authToken} currentUserId={currentUserId} oidcName={oidcName} onOpenShare={onOpenShare} />}
|
||||
|
||||
{section === 'tree' && (
|
||||
<form className="ts-panel" onSubmit={handleSaveTree}>
|
||||
@@ -649,7 +676,28 @@ const AdminPanel = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'danger' && (
|
||||
{section === 'danger' && !isOwner && (
|
||||
<div className="ts-panel">
|
||||
<div className="ts-danger">
|
||||
<div className="ts-row-text">
|
||||
<strong>{t('Quitter la gestion de cet arbre')}</strong>
|
||||
<p className="pz-small">{t('Vous ne pourrez plus le modifier. Seul son propriétaire peut le supprimer.')}</p>
|
||||
</div>
|
||||
{confirmLeave ? (
|
||||
<div className="ts-row-actions">
|
||||
<button type="button" className="contrib-confirm-no" onClick={() => setConfirmLeave(false)}>{t('Rester')}</button>
|
||||
<button type="button" className="pz-btn pz-btn--danger pz-btn--sm" onClick={handleLeave} disabled={saving}>
|
||||
<PzBusy busy={saving} busyLabel={t('En cours')}>{t('Quitter')}</PzBusy>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className="pz-btn pz-btn--danger-ghost pz-btn--sm" onClick={() => setConfirmLeave(true)}>{t('Quitter')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'danger' && isOwner && (
|
||||
<div className="ts-panel">
|
||||
<div className="ts-danger">
|
||||
<div className="ts-row-text">
|
||||
|
||||
@@ -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/<jeton>, 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 = <p className="pz-small ts-loading">{t('Chargement…')}</p>
|
||||
} else if (!invitation) {
|
||||
body = (
|
||||
<>
|
||||
<div className="pz-auth-head">
|
||||
<h1 className="pz-title">{t('Invitation')}</h1>
|
||||
<p className="pz-sub">{GONE[preview.error] || GONE.invitation_invalid}</p>
|
||||
</div>
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--block" onClick={onOpenApp}>{t('Ouvrir Parenthèse')}</button>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
const head = (
|
||||
<div className="pz-auth-head">
|
||||
<h1 className="pz-title">{t('Gérer « {name} »', { name: invitation.treeName })}</h1>
|
||||
<p className="pz-sub">
|
||||
{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 })}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
if (userToken) {
|
||||
body = (
|
||||
<>
|
||||
{head}
|
||||
<p className="pz-small">{t('Vous êtes connecté avec {email}.', { email: userEmail })}</p>
|
||||
{acceptError && <div className="account-error pz-error" role="alert">{acceptError}</div>}
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--block" onClick={handleAccept} disabled={accepting}>
|
||||
<PzBusy busy={accepting} busyLabel={t('Ouverture de l’arbre')}>{t('Accepter et ouvrir l’arbre')}</PzBusy>
|
||||
</button>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--block" onClick={onLogout} disabled={accepting}>{t('Changer de compte')}</button>
|
||||
</>
|
||||
)
|
||||
} else if (mode === 'choose') {
|
||||
body = (
|
||||
<>
|
||||
{head}
|
||||
<div className="invite-doors">
|
||||
{invitation.oidc && onOidcLogin && (
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--block" onClick={onOidcLogin}>
|
||||
<LogIn size={16} aria-hidden="true" />
|
||||
{t('Se connecter avec {name}', { name: invitation.oidc.name })}
|
||||
</button>
|
||||
)}
|
||||
{invitation.oidc && invitation.passwordLogin && <div className="pz-divider">{t('ou')}</div>}
|
||||
{invitation.passwordLogin && (
|
||||
<>
|
||||
<button type="button" className={`pz-btn pz-btn--block ${invitation.oidc ? 'pz-btn--secondary' : 'pz-btn--primary'}`} onClick={() => setMode('login')}>{t('J’ai déjà un compte Parenthèse')}</button>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--block" onClick={() => setMode('register')}>{t('Créer mon compte')}</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="pz-hint">{t('Le lien sert une fois et vaut 14 jours.')}</p>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
const isRegister = mode === 'register'
|
||||
body = (
|
||||
<>
|
||||
{head}
|
||||
<form className="pz-auth-form" onSubmit={handleSubmit}>
|
||||
{isRegister && (
|
||||
<div className="pz-field">
|
||||
<label htmlFor="inviteFirstName">{t('Prénom')}</label>
|
||||
<input id="inviteFirstName" type="text" autoComplete="given-name" value={firstName} maxLength={100} onChange={(event) => setFirstName(event.target.value)} required />
|
||||
</div>
|
||||
)}
|
||||
<div className="pz-field">
|
||||
<label htmlFor="inviteEmail">{t('Email')}</label>
|
||||
<input id="inviteEmail" type="email" autoComplete="email" value={email} onChange={(event) => setEmail(event.target.value)} required />
|
||||
<p className="pz-hint">{t('L’adresse à laquelle vous avez été invité.')}</p>
|
||||
</div>
|
||||
<div className="pz-field">
|
||||
<label htmlFor="invitePassword">{t('Mot de passe')}</label>
|
||||
<input id="invitePassword" type="password" autoComplete={isRegister ? 'new-password' : 'current-password'} value={password} onChange={(event) => setPassword(event.target.value)} minLength={8} required />
|
||||
{isRegister && <p className="pz-hint">{t('8 caractères minimum.')}</p>}
|
||||
</div>
|
||||
{errorMessage && <div className="account-error pz-error" role="alert">{errorMessage}</div>}
|
||||
<button type="submit" className="pz-btn pz-btn--primary pz-btn--block" disabled={loading || !email.trim() || password.length < 8}>
|
||||
<PzBusy busy={loading} busyLabel={isRegister ? t('Création du compte') : t('Connexion')}>
|
||||
{isRegister ? t('Créer mon compte et ouvrir l’arbre') : t('Se connecter')}
|
||||
</PzBusy>
|
||||
</button>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--block" onClick={() => setMode('choose')} disabled={loading}>{t('Retour')}</button>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pz-screen invitation-screen">
|
||||
<div className="pz-screen-inner">
|
||||
<img src={logoUrl} alt="Parenthèse" className="pz-screen-logo" />
|
||||
<div className="pz-card pz-screen-card pz-auth">{body}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InvitationScreen
|
||||
@@ -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; }
|
||||
@@ -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 <p className="pz-small ts-loading">{t('Chargement…')}</p>
|
||||
if (state.error) return <div className="pz-error" role="alert">{state.error}</div>
|
||||
|
||||
const { canManage, managers, invitations } = state.data
|
||||
return (
|
||||
<div className="ts-panel">
|
||||
<p className="pz-small">{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.')}</p>
|
||||
|
||||
{managers.map((manager) => {
|
||||
const isYou = manager.userId === currentUserId
|
||||
return (
|
||||
<div key={manager.userId} className="ts-row tm-row">
|
||||
<div className="tm-who">
|
||||
<span className="tm-avatar" aria-hidden="true">{initialOf(manager)}</span>
|
||||
<div className="ts-row-text">
|
||||
<strong>{isYou ? t('Vous') : (manager.firstName || manager.email)}</strong>
|
||||
<span className="tm-mail">{manager.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ts-row-actions">
|
||||
<span className="pz-tag">{manager.role === 'owner' ? t('Propriétaire') : t('Co-gestionnaire')}</span>
|
||||
{canManage && manager.role === 'admin' && (confirmRemove === manager.userId ? (
|
||||
<>
|
||||
<button type="button" className="contrib-confirm-no" onClick={() => setConfirmRemove(null)}>{t('Garder')}</button>
|
||||
<button type="button" className="contrib-confirm-yes" onClick={() => handleRemove(manager)} disabled={Boolean(busy)}>
|
||||
<PzBusy busy={busy === `remove-${manager.userId}`} busyLabel={t('Retrait en cours')}>{t('Retirer')}</PzBusy>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="contrib-pill-btn contrib-pill-btn--ghost" onClick={() => setConfirmRemove(manager.userId)}>{t('Retirer')}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{invitations.map((invitation) => (
|
||||
<div key={invitation.id} className="ts-row tm-row">
|
||||
<div className="tm-who">
|
||||
<span className="tm-avatar tm-avatar--pending" aria-hidden="true">{initialOf(invitation)}</span>
|
||||
<div className="ts-row-text">
|
||||
<strong>{invitation.email}</strong>
|
||||
<span className="tm-mail">{t('Invité le {date}, en attente', { date: new Date(invitation.createdAt).toLocaleDateString(dateLocale, { day: 'numeric', month: 'short' }) })}</span>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="ts-row-actions">
|
||||
<button type="button" className="contrib-pill-btn contrib-pill-btn--ghost" onClick={() => handleCopy(invitation)} disabled={Boolean(busy)}>
|
||||
<PzBusy busy={busy === `link-${invitation.id}`} busyLabel={t('Création du lien')}>{t('Copier le lien')}</PzBusy>
|
||||
</button>
|
||||
<button type="button" className="contrib-pill-btn contrib-pill-btn--ghost" onClick={() => handleRevoke(invitation)} disabled={Boolean(busy)}>
|
||||
<PzBusy busy={busy === `revoke-${invitation.id}`} busyLabel={t('Annulation en cours')}>{t('Annuler')}</PzBusy>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{link && (
|
||||
<div className="contrib-invite" role="status">
|
||||
<div className="contrib-invite-top">
|
||||
<span className="contrib-invite-title">{t('Lien pour {email}', { email: link.email })}</span>
|
||||
{link.copied && <span className="contrib-copy-feedback">{t('Lien copié')}</span>}
|
||||
</div>
|
||||
<div className="contrib-invite-link">
|
||||
<input className="contrib-link-url" value={link.url} readOnly aria-label={t('Lien d’invitation')} onFocus={(event) => event.target.select()} />
|
||||
</div>
|
||||
<p className="contrib-pass-hint">{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.')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{removedName && (
|
||||
<div className="contrib-invite" role="status">
|
||||
<span className="contrib-invite-title">{t('{name} ne gère plus cet arbre.', { name: removedName })}</span>
|
||||
<p className="contrib-pass-hint">{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.')}</p>
|
||||
{onOpenShare && (
|
||||
<button type="button" className="contrib-pill-btn contrib-pill-btn--save tm-share-btn" onClick={onOpenShare}>{t('Changer le mot de passe de partage')}</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.error && <div className="pz-error" role="alert">{message.error}</div>}
|
||||
{message.success && <div className="pz-success" role="status">{message.success}</div>}
|
||||
|
||||
{canManage && (
|
||||
<form onSubmit={handleInvite}>
|
||||
<fieldset className="ts-policy">
|
||||
<legend className="pz-eyebrow">{t('Inviter un co-gestionnaire')}</legend>
|
||||
<div className="pz-field">
|
||||
<label htmlFor="managerInviteEmail">{t('Son adresse email')}</label>
|
||||
<div className="tm-invite">
|
||||
<input
|
||||
id="managerInviteEmail"
|
||||
className="pz-input"
|
||||
type="email"
|
||||
autoComplete="off"
|
||||
placeholder={t('prenom@exemple.fr')}
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
<button type="submit" className="pz-btn pz-btn--primary" disabled={!email.trim() || Boolean(busy)}>
|
||||
<PzBusy busy={busy === 'invite'} busyLabel={t('Invitation en cours')}>{t('Inviter')}</PzBusy>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="pz-hint">
|
||||
{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.')}
|
||||
</p>
|
||||
</fieldset>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TreeManagersSection
|
||||
@@ -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(<PersonCard person={person} onQuickEdit={noop} onSave={noop} />)).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(<PersonCard person={person} onSave={noop} />)).not.toContain('pc-quick-edit')
|
||||
expect(renderToStaticMarkup(<PersonCard person={person} onQuickEdit={noop} editMode onSave={noop} />)).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(<TreeDropdown trees={[{ id: 't1', name: 'Arbre de démo', role: 'admin' }]} activeTreeId="t1" onSelect={noop} onCreate={noop} />)
|
||||
expect(html).toContain('Co-gestionnaire')
|
||||
expect(html).not.toContain('>Admin<')
|
||||
})
|
||||
|
||||
it('le panneau a un onglet Gestion', () => {
|
||||
const html = renderToStaticMarkup(<AdminPanel visible treeId="t1" authToken="x" isAdmin onClose={noop} />)
|
||||
expect(html).toContain('Gestion')
|
||||
})
|
||||
})
|
||||
@@ -41,7 +41,7 @@ function TreeDropdown({ trees = [], activeTreeId, onSelect, onCreate }) {
|
||||
<div className="tree-dropdown-item-info">
|
||||
<div className="tree-dropdown-item-name">{tree.name}</div>
|
||||
<div className="tree-dropdown-item-role">
|
||||
{tree.role === 'owner' ? t('Propriétaire') : t('Admin')}
|
||||
{tree.role === 'owner' ? t('Propriétaire') : t('Co-gestionnaire')}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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.',
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.')
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user