Invitation de la famille : mot de passe toujours à jour, message prêt à copier

Le panneau du propriétaire affichait la copie gardée par le navigateur où le
mot de passe avait été défini : changé depuis un autre appareil, il montrait
l'ancien. Le serveur garde maintenant le mot de passe de partage chiffré
(AES-256-GCM, clé dérivée de JWT_SECRET) dans une table à part, pour ne pas
toucher la version des accès déjà ouverts.

- GET /trees/:id/access/passwords : le mot de passe en vigueur, admin seulement
- POST .../remember : arbres d'avant, le propriétaire saisit celui déjà donné,
  vérifié contre les hash, retenu sans rien changer aux accès
- rotation à deux mots de passe distincts : plus de valeur lisible
- anciennes copies locales effacées à l'ouverture du panneau
- message d'invitation (lien, mot de passe, une phrase) modifiable et à copier

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-09-25 17:18:18 +02:00
1 parent 8bfc78b2f5
commit 92a4e5e1b9
9 files changed
+373 -42

No files matched your search

@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "tree_share_passwords" (
"treeId" TEXT NOT NULL,
"passwordEnc" TEXT NOT NULL,
"setAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tree_share_passwords_pkey" PRIMARY KEY ("treeId")
);
-- AddForeignKey
ALTER TABLE "tree_share_passwords" ADD CONSTRAINT "tree_share_passwords_treeId_fkey" FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+12
View File
@@ -109,6 +109,7 @@ model Tree {
deletedAt DateTime?
owner User @relation("TreeOwner", fields: [ownerUserId], references: [id], onDelete: Restrict)
accessPasswords TreeAccessPasswords?
sharePassword TreeSharePassword?
memberships TreeMembership[]
sharedUserAccesses UserTreeAccess[]
settings TreeSettings?
@@ -135,6 +136,17 @@ model TreeAccessPasswords {
@@map("tree_access_passwords")
}
// Mot de passe de partage en clair chiffré, pour que le propriétaire voie toujours celui en vigueur.
// Table à part : écrire dans tree_access_passwords changerait updatedAt, qui sert de version aux accès ouverts.
model TreeSharePassword {
treeId String @id
passwordEnc String
setAt DateTime @default(now())
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@map("tree_share_passwords")
}
model TreeMembership {
id String @id @default(cuid())
treeId String
+35
View File
@@ -0,0 +1,35 @@
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto'
import { env } from '../config/env.js'
// Le mot de passe de partage est fait pour être donné à la famille : le propriétaire doit pouvoir le relire.
// Chiffré en AES-256-GCM, clé dérivée de JWT_SECRET (changer ce secret rend les valeurs illisibles,
// le panneau repasse alors en « à saisir », sans rien casser pour la famille).
const VERSION = 'v1'
function getKey(): Buffer {
return Buffer.from(hkdfSync('sha256', env.JWT_SECRET, 'parenthese', 'share-password-v1', 32))
}
export function encryptSharePassword(value: string): string {
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', getKey(), iv)
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return `${VERSION}:${Buffer.concat([iv, tag, encrypted]).toString('base64')}`
}
export function decryptSharePassword(stored: string | null | undefined): string | null {
if (!stored || !stored.startsWith(`${VERSION}:`)) {
return null
}
try {
const raw = Buffer.from(stored.slice(VERSION.length + 1), 'base64')
const decipher = createDecipheriv('aes-256-gcm', getKey(), raw.subarray(0, 12))
decipher.setAuthTag(raw.subarray(12, 28))
return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8')
} catch {
return null
}
}
+87
View File
@@ -4,6 +4,7 @@ import { z } from 'zod'
import { env } from '../config/env.js'
import { hashPassword, verifyPassword } from '../lib/auth.js'
import { decryptSharePassword, encryptSharePassword } from '../lib/share-password.js'
import { purgeTrees } from '../lib/tree-purge.js'
import type { MembershipRole } from '../types/auth.js'
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
@@ -50,6 +51,10 @@ const patchPasswordsSchema = z
message: 'at_least_one_password_required',
})
const rememberPasswordSchema = z.object({
password: z.string().min(1).max(128),
})
const patchTreeSettingsSchema = z
.object({
memberContributionPolicy: z.enum(['direct', 'pending']).optional(),
@@ -419,6 +424,9 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
contributorHash,
},
},
...(parsed.data.visitorPassword === parsed.data.contributorPassword
? { sharePassword: { create: { passwordEnc: encryptSharePassword(parsed.data.visitorPassword) } } }
: {}),
memberships: {
create: {
userId: user.userId,
@@ -838,6 +846,73 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
return reply.send({ treeId: params.data.id, role })
})
// Le mot de passe de partage en vigueur, pour le propriétaire (null si jamais retenu par le serveur)
app.get('/trees/:id/access/passwords', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) {
return
}
const params = treeIdParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_tree_id' })
}
const membership = await getMembership(app, user.userId, params.data.id)
if (!membership || !hasAdminRight(membership.role)) {
return reply.code(403).send({ error: 'forbidden' })
}
const stored = await app.prisma.treeSharePassword.findUnique({ where: { treeId: params.data.id } })
return reply.send({ share: decryptSharePassword(stored?.passwordEnc) })
})
// Arbres d'avant : le propriétaire tape le mot de passe déjà donné, le serveur le vérifie et le retient
// sans rien changer aux accès (les hash et leur updatedAt ne bougent pas).
app.post('/trees/:id/access/passwords/remember', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) {
return
}
const params = treeIdParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_tree_id' })
}
const payload = rememberPasswordSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() })
}
const membership = await getMembership(app, user.userId, params.data.id)
if (!membership || !hasAdminRight(membership.role)) {
return reply.code(403).send({ error: 'forbidden' })
}
const accessConfig = await app.prisma.treeAccessPasswords.findUnique({ where: { treeId: params.data.id } })
if (!accessConfig) {
return reply.code(404).send({ error: 'tree_not_found' })
}
const password = payload.data.password.trim()
const matches =
(await verifyPassword(password, accessConfig.contributorHash)) ||
(await verifyPassword(password, accessConfig.visitorHash))
if (!matches) {
return reply.code(422).send({ error: 'password_mismatch' })
}
const passwordEnc = encryptSharePassword(password)
await app.prisma.treeSharePassword.upsert({
where: { treeId: params.data.id },
create: { treeId: params.data.id, passwordEnc },
update: { passwordEnc, setAt: new Date() },
})
return reply.send({ share: password })
})
app.patch('/trees/:id/access/passwords', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) {
@@ -891,6 +966,18 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
data,
})
// Le mot de passe lisible suit toujours celui en vigueur ; deux mots de passe distincts = plus de valeur unique
if (payload.data.password) {
const passwordEnc = encryptSharePassword(payload.data.password)
await app.prisma.treeSharePassword.upsert({
where: { treeId: params.data.id },
create: { treeId: params.data.id, passwordEnc },
update: { passwordEnc, setAt: new Date() },
})
} else {
await app.prisma.treeSharePassword.deleteMany({ where: { treeId: params.data.id } })
}
await app.prisma.auditLog.create({
data: {
treeId: params.data.id,
@@ -31,6 +31,11 @@ const { prismaMock } = vi.hoisted(() => ({
create: vi.fn(),
update: vi.fn(),
},
treeSharePassword: {
findUnique: vi.fn(),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
userTreeAccess: {
findMany: vi.fn(),
findUnique: vi.fn(),
@@ -164,6 +169,9 @@ beforeEach(() => {
})
prismaMock.userTreeAccess.updateMany.mockResolvedValue({ count: 0 })
prismaMock.treeAccessPasswords.update.mockResolvedValue({ treeId: 'tree-1' })
prismaMock.treeSharePassword.findUnique.mockResolvedValue(null)
prismaMock.treeSharePassword.upsert.mockResolvedValue({ treeId: 'tree-1' })
prismaMock.treeSharePassword.deleteMany.mockResolvedValue({ count: 0 })
prismaMock.treeSettings.findUnique.mockResolvedValue({
treeId: 'tree-1',
memberContributionPolicy: 'pending',
@@ -673,6 +681,101 @@ describe('API security and authz', () => {
await app.close()
})
// Mot de passe toujours à jour chez le propriétaire (25/09/2026) : gardé chiffré par le serveur
it('keeps the new share password readable for the owner', async () => {
const { decryptSharePassword } = await import('../src/lib/share-password')
const app = createApp()
const token = await createUserToken(app)
await app.inject({
method: 'PATCH',
url: '/trees/tree-1/access/passwords',
headers: { authorization: `Bearer ${token}` },
payload: { password: 'lilas-en-avril' },
})
const stored = prismaMock.treeSharePassword.upsert.mock.calls.at(-1)?.[0]?.create?.passwordEnc
expect(stored).not.toContain('lilas-en-avril')
expect(decryptSharePassword(stored)).toBe('lilas-en-avril')
prismaMock.treeSharePassword.findUnique.mockResolvedValue({ treeId: 'tree-1', passwordEnc: stored })
const read = await app.inject({
method: 'GET',
url: '/trees/tree-1/access/passwords',
headers: { authorization: `Bearer ${token}` },
})
expect(read.statusCode).toBe(200)
expect(read.json().share).toBe('lilas-en-avril')
await app.close()
})
it('forgets the readable password when two different passwords are set', async () => {
const app = createApp()
const token = await createUserToken(app)
await app.inject({
method: 'PATCH',
url: '/trees/tree-1/access/passwords',
headers: { authorization: `Bearer ${token}` },
payload: { visitorPassword: 'regarder-seul' },
})
expect(prismaMock.treeSharePassword.deleteMany).toHaveBeenCalledWith({ where: { treeId: 'tree-1' } })
expect(prismaMock.treeSharePassword.upsert).not.toHaveBeenCalled()
await app.close()
})
it('remembers the current password without changing access', async () => {
const { hashPassword } = await import('../src/lib/auth')
const app = createApp()
const token = await createUserToken(app)
prismaMock.treeAccessPasswords.findUnique.mockResolvedValue({
treeId: 'tree-1',
visitorHash: await hashPassword('ancien-visiteur'),
contributorHash: await hashPassword('celui-de-la-famille'),
updatedAt: new Date('2026-02-11T10:00:00.000Z'),
tree: { deletedAt: null },
})
const wrong = await app.inject({
method: 'POST',
url: '/trees/tree-1/access/passwords/remember',
headers: { authorization: `Bearer ${token}` },
payload: { password: 'pas-le-bon' },
})
expect(wrong.statusCode).toBe(422)
expect(prismaMock.treeSharePassword.upsert).not.toHaveBeenCalled()
const right = await app.inject({
method: 'POST',
url: '/trees/tree-1/access/passwords/remember',
headers: { authorization: `Bearer ${token}` },
payload: { password: 'celui-de-la-famille' },
})
expect(right.statusCode).toBe(200)
expect(right.json().share).toBe('celui-de-la-famille')
expect(prismaMock.treeSharePassword.upsert).toHaveBeenCalledTimes(1)
expect(prismaMock.treeAccessPasswords.update).not.toHaveBeenCalled()
await app.close()
})
it('refuses the readable password to anyone but an admin', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.treeMembership.findUnique.mockResolvedValue(null)
const read = await app.inject({
method: 'GET',
url: '/trees/tree-1/access/passwords',
headers: { authorization: `Bearer ${token}` },
})
expect(read.statusCode).toBe(403)
const anonymous = await app.inject({ method: 'GET', url: '/trees/tree-1/access/passwords' })
expect(anonymous.statusCode).toBe(401)
await app.close()
})
// Contributions fiables (25/09/2026) : mot d'accompagnement, liens d'une personne ajoutée
const signShareToken = (app: any) => app.jwt.sign({
kind: 'tree_access',
+49 -31
View File
@@ -31,6 +31,8 @@ import {
linkTreeAccess,
updateTree,
rotateTreePasswords,
fetchSharePassword,
rememberSharePassword,
resolveTreeBySlug,
uploadPersonAvatar,
updatePerson,
@@ -423,36 +425,17 @@ function buildContribChangesList(draft = {}) {
return items
}
function getInvitePasswordStorageKey(treeId, role) {
return `invite_password_${treeId}_${role}`
}
// Mot de passe de partage connu de ce navigateur (le serveur ne garde qu'un hash).
// Repli sur les anciennes clés « contributor » puis « visitor » d'avant le mot de passe unique.
function readInvitePasswordsFromStorage(treeId) {
// Anciennes copies locales du mot de passe (avant qu'il soit gardé par le serveur) : on les efface,
// elles pouvaient montrer un mot de passe changé depuis un autre appareil.
function clearLegacyInvitePasswords(treeId) {
if (!treeId) {
return { share: '' }
}
try {
return {
share: localStorage.getItem(getInvitePasswordStorageKey(treeId, 'share'))
|| localStorage.getItem(getInvitePasswordStorageKey(treeId, 'contributor'))
|| localStorage.getItem(getInvitePasswordStorageKey(treeId, 'visitor'))
|| '',
}
} catch {
return { share: '' }
}
}
function writeInvitePasswordsToStorage(treeId, passwords) {
if (!treeId || !passwords.share) {
return
}
try {
localStorage.setItem(getInvitePasswordStorageKey(treeId, 'share'), passwords.share)
for (const role of ['share', 'contributor', 'visitor']) {
localStorage.removeItem(`invite_password_${treeId}_${role}`)
}
} catch {
// Ignore localStorage failures
}
@@ -1240,16 +1223,52 @@ function App() {
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId])
const handleOpenContributionPanel = useCallback(async () => {
const knownPasswords = readInvitePasswordsFromStorage(tree.treeContext.treeId)
const treeId = tree.treeContext.treeId
clearLegacyInvitePasswords(treeId)
await resolveContributionShareUrl()
let share = ''
const accessToken = auth.userAuth.token || tree.treeContext.accessToken
if (treeId && accessToken && tree.canEditCurrentTree) {
try {
share = (await fetchSharePassword(treeId, accessToken))?.share || ''
} catch {
share = ''
}
}
setContribInviteState((current) => ({
...current,
knownPasswords,
knownPasswords: { share },
error: '',
message: '',
}))
await contrib.handleOpenContributionPanel()
}, [contrib, resolveContributionShareUrl, tree.treeContext.treeId]) // eslint-disable-line react-hooks/exhaustive-deps
}, [contrib, resolveContributionShareUrl, tree.treeContext.treeId, tree.canEditCurrentTree, tree.treeContext.accessToken, auth.userAuth.token]) // eslint-disable-line react-hooks/exhaustive-deps
const handleContributionPasswordRemember = useCallback(async ({ password }) => {
const accessToken = auth.userAuth.token || tree.treeContext.accessToken
if (!tree.treeContext.treeId || !accessToken) {
return { ok: false, error: 'Session invalide.' }
}
setContribInviteState((current) => ({ ...current, loading: true, error: '', message: '' }))
try {
const result = await rememberSharePassword(tree.treeContext.treeId, String(password || '').trim(), accessToken)
setContribInviteState((current) => ({
...current,
knownPasswords: { share: result?.share || '' },
loading: false,
error: '',
message: "C'est bien lui. Rien ne change pour la famille.",
}))
return { ok: true }
} catch (error) {
const message = error?.status === 422
? "Ce n'est pas le mot de passe actuel de l'arbre."
: getAccountErrorMessage(error)
setContribInviteState((current) => ({ ...current, loading: false, error: message, message: '' }))
return { ok: false, error: message }
}
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId])
const handleContributionPasswordRotate = useCallback(async ({ password }) => {
if (!tree.treeContext.treeId) {
@@ -1288,7 +1307,6 @@ function App() {
try {
await rotateTreePasswords(tree.treeContext.treeId, payload, accessToken)
const storedPasswords = { share: nextPassword }
writeInvitePasswordsToStorage(tree.treeContext.treeId, storedPasswords)
setContribInviteState((current) => ({
...current,
@@ -2149,7 +2167,7 @@ function App() {
? normalizedSlug
: `famille-${Date.now().toString(36)}`
// Un seul mot de passe de partage, gardé dans ce navigateur pour que le propriétaire le voie
// Un seul mot de passe de partage, gardé chiffré par le serveur pour que le propriétaire le voie
const sharePassword = generateSecurePassword()
const createdTree = await createTree(
{
@@ -2161,7 +2179,6 @@ function App() {
},
token,
)
writeInvitePasswordsToStorage(createdTree.id, { share: sharePassword })
trackAppEvent('tree_created', { tree_id: toOpaqueTreeId(createdTree.id) })
const createdSelf = await createPerson(
@@ -2603,6 +2620,7 @@ function App() {
onClose={contrib.handleCloseContributionPanel}
onRefresh={contrib.refreshContributionSessions}
onRotatePasswords={handleContributionPasswordRotate}
onRememberPassword={handleContributionPasswordRemember}
onReviewSession={contrib.handleReviewContributionSession}
/>
<AccountPanel
+19
View File
@@ -212,6 +212,25 @@ export async function rotateTreePasswords(treeId, payload, token) {
return data
}
export async function fetchSharePassword(treeId, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/access/passwords`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
})
}
export async function rememberSharePassword(treeId, password, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/access/passwords/remember`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ password }),
})
}
export async function fetchTreeStats(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/stats`, {
method: 'GET',
+52 -11
View File
@@ -167,6 +167,20 @@ function formatSessionDate(value) {
})
}
// Message d'invitation prêt à envoyer par mail, SMS ou WhatsApp
function buildInviteText(url, password) {
return [
'Bonjour à tous,',
'',
"Je rassemble l'histoire de notre famille dans un arbre en ligne, et j'aimerais que vous m'aidiez à le compléter.",
'',
`L'arbre est ici ${url}`,
`Le mot de passe pour l'ouvrir est ${password}`,
'',
'Avec le crayon en bas à droite, vous pouvez ajouter une photo, un souvenir ou une personne qui manque.',
].join('\n')
}
// ─── component ──────────────────────────────────────────────────────────────
const ContributionPanel = ({
@@ -186,13 +200,15 @@ const ContributionPanel = ({
onClose,
onRefresh,
onRotatePasswords,
onRememberPassword,
onReviewSession,
}) => {
const [decisionMap, setDecisionMap] = useState({})
const [collapsedSessions, setCollapsedSessions] = useState(new Set())
const [confirmApproveAll, setConfirmApproveAll] = useState(null)
const [showPassword, setShowPassword] = useState(false)
const [editingPassword, setEditingPassword] = useState(false)
// '' | 'change' (nouveau mot de passe) | 'remember' (arbre d'avant : saisir celui déjà donné)
const [passwordMode, setPasswordMode] = useState('')
const [newPassword, setNewPassword] = useState('')
const [inviteLocalError, setInviteLocalError] = useState('')
const [copyFeedback, setCopyFeedback] = useState('')
@@ -279,11 +295,16 @@ const ContributionPanel = ({
useEffect(() => {
if (!visible) return
setEditingPassword(false)
setPasswordMode('')
setNewPassword('')
setInviteLocalError('')
}, [inviteKnownPasswords?.share, visible])
const knownShare = inviteKnownPasswords?.share || ''
const defaultInviteText = knownShare && inviteShareUrl ? buildInviteText(inviteShareUrl, knownShare) : ''
const [inviteText, setInviteText] = useState('')
useEffect(() => { setInviteText(defaultInviteText) }, [defaultInviteText])
const handleCopy = async (value, label) => {
const text = String(value || '').trim()
if (!text) return
@@ -294,9 +315,15 @@ const ContributionPanel = ({
const handleSavePassword = async (e) => {
e.preventDefault()
setInviteLocalError('')
if (passwordMode === 'remember') {
if (!newPassword.trim()) return
const result = await onRememberPassword?.({ password: newPassword.trim() })
if (result?.ok) { setPasswordMode(''); setNewPassword('') }
return
}
if (newPassword.trim().length < 8) { setInviteLocalError('Minimum 8 caractères.'); return }
const result = await onRotatePasswords?.({ password: newPassword.trim() })
if (result?.ok) { setEditingPassword(false); setNewPassword('') }
if (result?.ok) { setPasswordMode(''); setNewPassword('') }
}
if (!visible) return null
@@ -334,22 +361,36 @@ const ContributionPanel = ({
<div className="contrib-invite-passwords">
<div className="contrib-role-row">
<span className="contrib-role-badge"><strong>Mot de passe</strong><small>pour regarder et ajouter</small></span>
{editingPassword ? (
{passwordMode ? (
<form className="contrib-pass-edit-inline" onSubmit={handleSavePassword}>
<input className="contrib-pass-edit-input" type="text" value={newPassword} placeholder="8 caractères minimum" autoFocus autoComplete="off" onChange={(e) => setNewPassword(e.target.value)} />
<button type="submit" className="contrib-pill-btn contrib-pill-btn--save" disabled={inviteSaving}><PzBusy busy={inviteSaving} busyLabel="Enregistrement en cours">Enregistrer</PzBusy></button>
<button type="button" className="contrib-pill-btn contrib-pill-btn--ghost" onClick={() => { setEditingPassword(false); setNewPassword(''); setInviteLocalError('') }}>Annuler</button>
<input className="contrib-pass-edit-input" type="text" value={newPassword} placeholder={passwordMode === 'remember' ? 'Celui déjà donné à la famille' : '8 caractères minimum'} aria-label={passwordMode === 'remember' ? 'Mot de passe actuel' : 'Nouveau mot de passe'} autoFocus autoComplete="off" onChange={(e) => setNewPassword(e.target.value)} />
<button type="submit" className="contrib-pill-btn contrib-pill-btn--save" disabled={inviteSaving}><PzBusy busy={inviteSaving} busyLabel="Enregistrement en cours">{passwordMode === 'remember' ? 'Vérifier' : 'Enregistrer'}</PzBusy></button>
<button type="button" className="contrib-pill-btn contrib-pill-btn--ghost" onClick={() => { setPasswordMode(''); setNewPassword(''); setInviteLocalError('') }}>Annuler</button>
</form>
) : (
<>
<button type="button" className={`contrib-pass-reveal${inviteKnownPasswords?.share && !showPassword ? ' contrib-pass-reveal--blurred' : ''}`} onClick={() => setShowPassword((v) => !v)} disabled={!inviteKnownPasswords?.share} title={showPassword ? 'Cliquer pour masquer' : 'Cliquer pour révéler'}>{inviteKnownPasswords?.share || 'Inconnu sur cet appareil'}</button>
{inviteKnownPasswords?.share && <button type="button" className="contrib-pill-btn contrib-pill-btn--edit" onClick={() => handleCopy(inviteKnownPasswords.share, 'Mot de passe copié')}>Copier</button>}
<button type="button" className="contrib-pill-btn contrib-pill-btn--edit" disabled={inviteSaving} onClick={() => setEditingPassword(true)}>{inviteKnownPasswords?.share ? 'Changer' : 'Définir'}</button>
<button type="button" className={`contrib-pass-reveal${knownShare && !showPassword ? ' contrib-pass-reveal--blurred' : ''}`} onClick={() => setShowPassword((v) => !v)} disabled={!knownShare} title={showPassword ? 'Cliquer pour masquer' : 'Cliquer pour révéler'}>{knownShare || 'Pas encore enregistré'}</button>
{knownShare && <button type="button" className="contrib-pill-btn contrib-pill-btn--edit" onClick={() => handleCopy(knownShare, 'Mot de passe copié')}>Copier</button>}
{!knownShare && <button type="button" className="contrib-pill-btn contrib-pill-btn--edit" disabled={inviteSaving} onClick={() => setPasswordMode('remember')}>Saisir l'actuel</button>}
<button type="button" className="contrib-pill-btn contrib-pill-btn--edit" disabled={inviteSaving} onClick={() => setPasswordMode('change')}>Changer</button>
</>
)}
</div>
<p className="contrib-pass-hint">Changer le mot de passe oblige les personnes qui ont déjà ouvert l'arbre à saisir le nouveau.</p>
<p className="contrib-pass-hint">
{passwordMode === 'remember' || (!knownShare && !passwordMode)
? "Déjà donné à la famille ? Saisissez-le ici. Il est vérifié puis gardé, rien ne change pour ceux qui ont déjà ouvert l'arbre."
: "Changer le mot de passe oblige les personnes qui ont déjà ouvert l'arbre à saisir le nouveau."}
</p>
</div>
{defaultInviteText && (
<div className="contrib-invite-message">
<label className="contrib-invite-title" htmlFor="contrib-invite-text">Le message à envoyer</label>
<textarea id="contrib-invite-text" className="contrib-invite-textarea" rows={9} value={inviteText} onChange={(e) => setInviteText(e.target.value)} />
<div className="contrib-invite-message-actions">
<button type="button" className="pz-btn pz-btn--primary pz-btn--sm" onClick={() => handleCopy(inviteText, 'Message copié')}><Copy size={14} strokeWidth={2} />Copier le message</button>
</div>
</div>
)}
{(inviteLocalError || inviteError) && <div className="contrib-error">{inviteLocalError || inviteError}</div>}
{inviteMessage && <div className="contrib-ok">{inviteMessage}</div>}
</div>
@@ -53,6 +53,10 @@
.contrib-link-url { flex: 1; min-width: 0; font: 400 13px var(--pz-sans); color: var(--pz-ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; background: none; padding: 0; }
.contrib-invite-passwords { display: grid; gap: 6px; }
.contrib-pass-hint { margin: 2px 4px 0; font: 400 12.5px/1.45 var(--pz-sans); color: var(--pz-muted); }
.contrib-invite-message { display: grid; gap: 8px; }
.contrib-invite-textarea { width: 100%; box-sizing: border-box; padding: 12px 14px; background: var(--pz-surface); border: 1px solid var(--pz-line); border-radius: var(--pz-r-m); font: 400 13.5px/1.5 var(--pz-sans); color: var(--pz-ink-2); resize: vertical; outline: none; field-sizing: content; min-height: 9.5lh; }
.contrib-invite-textarea:focus { border-color: var(--pz-a500); box-shadow: 0 0 0 3px var(--pz-a100); }
.contrib-invite-message-actions { display: flex; justify-content: flex-end; }
.contrib-role-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 8px 8px 12px; background: var(--pz-surface); border: 1px solid var(--pz-line); border-radius: var(--pz-r-m); }
.contrib-role-badge { display: grid; gap: 1px; min-width: 130px; flex: 1; }
.contrib-role-badge strong { font: 500 14px var(--pz-sans); color: var(--pz-ink); }