mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-13 16:22:03 +00:00
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:
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in new issue
Block a user