From 5be134e0e6d241b19dddd414713cfaf696c01287 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sun, 25 Oct 2026 16:44:11 +0100 Subject: [PATCH 1/6] =?UTF-8?q?Co-gestion=20d'un=20arbre=20(#145)=20:=20in?= =?UTF-8?q?vitations,=20rattachement=20OIDC=20sur=20email=20v=C3=A9rifi?= =?UTF-8?q?=C3=A9,=20compte=20ouvert=20par=20invitation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../20261025_001_co_gestion/migration.sql | 32 +++ backend/prisma/schema.prisma | 24 ++ backend/src/app.ts | 2 + backend/src/lib/oidc.ts | 5 +- backend/src/lib/registration.ts | 5 +- backend/src/lib/tree-invitations.ts | 116 +++++++++ backend/src/routes/auth.ts | 26 +- backend/src/routes/managers.ts | 221 +++++++++++++++++ backend/src/routes/oidc.ts | 8 +- backend/src/routes/trees.ts | 15 ++ backend/test/co-gestion.db.test.ts | 225 ++++++++++++++++++ backend/test/oidc.test.ts | 6 + 12 files changed, 677 insertions(+), 8 deletions(-) create mode 100644 backend/prisma/migrations/20261025_001_co_gestion/migration.sql create mode 100644 backend/src/lib/tree-invitations.ts create mode 100644 backend/src/routes/managers.ts create mode 100644 backend/test/co-gestion.db.test.ts diff --git a/backend/prisma/migrations/20261025_001_co_gestion/migration.sql b/backend/prisma/migrations/20261025_001_co_gestion/migration.sql new file mode 100644 index 0000000..ef19a87 --- /dev/null +++ b/backend/prisma/migrations/20261025_001_co_gestion/migration.sql @@ -0,0 +1,32 @@ +-- Co-gestion d'un arbre (issue #145) : invitations du propriétaire. Migration additive, une table neuve. +-- CreateTable +CREATE TABLE "tree_invitations" ( + "id" TEXT NOT NULL, + "treeId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "invitedByUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "acceptedAt" TIMESTAMP(3), + "acceptedUserId" TEXT, + "revokedAt" TIMESTAMP(3), + + CONSTRAINT "tree_invitations_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "tree_invitations_tokenHash_key" ON "tree_invitations"("tokenHash"); + +-- CreateIndex +CREATE INDEX "tree_invitations_treeId_idx" ON "tree_invitations"("treeId"); + +-- CreateIndex +CREATE INDEX "tree_invitations_email_idx" ON "tree_invitations"("email"); + +-- AddForeignKey +ALTER TABLE "tree_invitations" ADD CONSTRAINT "tree_invitations_treeId_fkey" FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "tree_invitations" ADD CONSTRAINT "tree_invitations_invitedByUserId_fkey" FOREIGN KEY ("invitedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a6856a1..6d7fdaa 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -98,6 +98,7 @@ model User { reviewedContributionSessions ContributionSession[] @relation("ContributionReviewedBy") treeVisits TreeVisit[] identities UserIdentity[] + sentTreeInvitations TreeInvitation[] @relation("TreeInvitationSentBy") lastOpenedTree Tree? @relation("UserLastOpenedTree", fields: [lastOpenedTreeId], references: [id], onDelete: SetNull) @@index([lastOpenedAt]) @@ -146,6 +147,7 @@ model Tree { annotations Annotation[] visits TreeVisit[] lifeEvents LifeEvent[] + invitations TreeInvitation[] lastOpenedByUsers User[] @relation("UserLastOpenedTree") @@index([ownerUserId]) @@ -188,6 +190,28 @@ model TreeMembership { @@map("tree_memberships") } +// Invitation à co-gérer un arbre (issue #145) : le propriétaire donne une adresse, Parenthèse un lien à transmettre. +// Seule l'empreinte du jeton est gardée ; « Copier le lien » en tire un nouveau. Acceptée, elle donne le rôle admin +// (co-gestionnaire). Le lien sert une fois, jusqu'à expiresAt. +model TreeInvitation { + id String @id @default(cuid()) + treeId String + email String + tokenHash String @unique + invitedByUserId String? + createdAt DateTime @default(now()) + expiresAt DateTime + acceptedAt DateTime? + acceptedUserId String? + revokedAt DateTime? + tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) + invitedBy User? @relation("TreeInvitationSentBy", fields: [invitedByUserId], references: [id], onDelete: SetNull) + + @@index([treeId]) + @@index([email]) + @@map("tree_invitations") +} + model UserTreeAccess { id String @id @default(cuid()) treeId String diff --git a/backend/src/app.ts b/backend/src/app.ts index b47a640..d5d80d5 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -13,6 +13,7 @@ import { authRoutes } from './routes/auth.js' import { contributionRoutes } from './routes/contributions.js' import { graphRoutes } from './routes/graph.js' import { immichRoutes } from './routes/immich.js' +import { managerRoutes } from './routes/managers.js' import { mediaRoutes } from './routes/media.js' import { oidcRoutes } from './routes/oidc.js' import { publicRoutes } from './routes/public.js' @@ -159,6 +160,7 @@ export function createApp() { app.register(gedcomRoutes) app.register(visitRoutes) app.register(immichRoutes) + app.register(managerRoutes) app.setErrorHandler((error: FastifyError | Error, request, reply) => { if (error instanceof Prisma.PrismaClientInitializationError) { diff --git a/backend/src/lib/oidc.ts b/backend/src/lib/oidc.ts index 12a7798..69da4d7 100644 --- a/backend/src/lib/oidc.ts +++ b/backend/src/lib/oidc.ts @@ -50,9 +50,10 @@ export function appBaseUrl(): string { return (env.OIDC_APP_URL || new URL(env.OIDC_REDIRECT_URI).origin).replace(/\/$/, '') } -// Seules la racine et l'adresse d'un arbre partagé sont acceptées comme retour : pas de redirection ouverte +// Seules la racine, l'adresse d'un arbre partagé et celle d'une invitation à co-gérer un arbre sont acceptées +// comme retour : pas de redirection ouverte export function safeReturnTo(value: unknown): string { - return typeof value === 'string' && /^\/(arbre\/[A-Za-z0-9-]{3,64})?$/.test(value) ? value : '/' + return typeof value === 'string' && /^\/(arbre\/[A-Za-z0-9-]{3,64}|invitation\/[A-Za-z0-9_-]{20,100})?$/.test(value) ? value : '/' } export async function startAuthorization(returnTo: string): Promise<{ url: URL; pending: OidcPending }> { diff --git a/backend/src/lib/registration.ts b/backend/src/lib/registration.ts index dc98371..9b037ee 100644 --- a/backend/src/lib/registration.ts +++ b/backend/src/lib/registration.ts @@ -12,11 +12,14 @@ export async function isRegistrationOpen(prisma: PrismaClient): Promise // Seul chemin de création d'un compte. Inscription fermée : le compte n'est créé que si la base n'en a aucun, // sous un verrou de transaction pour que deux inscriptions simultanées sur une instance neuve n'en créent pas // deux. Renvoie null quand la création est refusée. +// invited : l'appelant a vérifié une invitation à co-gérer un arbre pour cette adresse (issue #145) ; le +// propriétaire de l'arbre a été admis par l'admin du serveur, son invitation ouvre un compte. export async function createAccountIfAllowed( prisma: PrismaClient, create: (tx: Prisma.TransactionClient) => Promise, + { invited = false }: { invited?: boolean } = {}, ): Promise { - if (env.ALLOW_REGISTRATION) return create(prisma) + if (env.ALLOW_REGISTRATION || invited) return create(prisma) return prisma.$transaction(async (tx) => { // Clé arbitraire, propre à la création de compte. pg_advisory_xact_lock renvoie void : $executeRaw, pas $queryRaw. diff --git a/backend/src/lib/tree-invitations.ts b/backend/src/lib/tree-invitations.ts new file mode 100644 index 0000000..d96ac70 --- /dev/null +++ b/backend/src/lib/tree-invitations.ts @@ -0,0 +1,116 @@ +import { createHash, randomBytes } from 'node:crypto' + +import type { Prisma, PrismaClient } from '@prisma/client' + +// Co-gestion d'un arbre (issue #145). Le propriétaire invite une adresse ; Parenthèse n'envoie pas de mail, il +// donne un lien à transmettre. Seule l'empreinte du jeton est gardée en base. +// +// Qui est rattaché, et comment : +// - par le lien, avec un compte à la même adresse (mot de passe ou OIDC). L'inscription par mot de passe ne +// vérifie pas l'adresse : le lien est la preuve, l'adresse seule ne suffit jamais ; +// - sans le lien, seulement par OIDC, quand le fournisseur dit l'email vérifié (claimInvitationsForVerifiedEmail). + +export const INVITATION_TTL_MS = 14 * 24 * 60 * 60 * 1000 +export const MAX_PENDING_INVITATIONS = 20 + +type Db = PrismaClient | Prisma.TransactionClient + +export function newInvitationToken(): { token: string; tokenHash: string } { + const token = randomBytes(32).toString('base64url') + return { token, tokenHash: hashInvitationToken(token) } +} + +export function hashInvitationToken(token: string): string { + return createHash('sha256').update(token).digest('hex') +} + +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase() +} + +// Invitation encore valable : ni acceptée, ni annulée, ni expirée +export function pendingInvitationWhere(now = new Date()): Prisma.TreeInvitationWhereInput { + return { acceptedAt: null, revokedAt: null, expiresAt: { gt: now }, tree: { deletedAt: null } } +} + +export async function findPendingInvitationByToken(db: Db, token: string) { + if (!token || token.length > 200) return null + return db.treeInvitation.findFirst({ + where: { tokenHash: hashInvitationToken(token), ...pendingInvitationWhere() }, + include: { tree: { select: { id: true, name: true, ownerUserId: true, owner: { select: { firstName: true } } } } }, + }) +} + +export async function hasPendingInvitationForEmail(db: Db, email: string): Promise { + const found = await db.treeInvitation.findFirst({ where: { email: normalizeEmail(email), ...pendingInvitationWhere() }, select: { id: true } }) + return Boolean(found) +} + +export type AcceptResult = { ok: true; treeId: string } | { ok: false; error: 'invitation_invalid' | 'invitation_email_mismatch' | 'already_owner' } + +// Accepte une invitation pour ce compte : co-gestionnaire (admin) de l'arbre. L'invitation ne sert qu'une fois, +// même pour deux acceptations simultanées (updateMany conditionnel dans la transaction). +export async function acceptInvitation(prisma: PrismaClient, invitationId: string, userId: string, userEmail: string): Promise { + return prisma.$transaction(async (tx) => { + const invitation = await tx.treeInvitation.findFirst({ + where: { id: invitationId, ...pendingInvitationWhere() }, + select: { id: true, treeId: true, email: true, tree: { select: { ownerUserId: true } } }, + }) + if (!invitation) return { ok: false, error: 'invitation_invalid' } as const + if (normalizeEmail(userEmail) !== invitation.email) return { ok: false, error: 'invitation_email_mismatch' } as const + if (invitation.tree.ownerUserId === userId) return { ok: false, error: 'already_owner' } as const + + const claimed = await tx.treeInvitation.updateMany({ + where: { id: invitation.id, ...pendingInvitationWhere() }, + data: { acceptedAt: new Date(), acceptedUserId: userId }, + }) + if (claimed.count !== 1) return { ok: false, error: 'invitation_invalid' } as const + + const existing = await tx.treeMembership.findUnique({ + where: { treeId_userId: { treeId: invitation.treeId, userId } }, + select: { role: true }, + }) + if (!existing) { + await tx.treeMembership.create({ data: { treeId: invitation.treeId, userId, role: 'admin' } }) + } else if (existing.role === 'member') { + await tx.treeMembership.update({ where: { treeId_userId: { treeId: invitation.treeId, userId } }, data: { role: 'admin' } }) + } + // L'arbre s'ouvre à la prochaine visite + await tx.user.update({ + where: { id: userId }, + data: { lastOpenedTreeId: invitation.treeId, lastOpenedAccessMode: 'member', lastOpenedRole: 'admin', lastOpenedAt: new Date() }, + }) + await tx.auditLog.create({ + data: { + treeId: invitation.treeId, + actorType: 'user', + actorId: userId, + action: 'manager_joined', + entityType: 'tree', + entityId: invitation.treeId, + payloadJson: { invitationId: invitation.id }, + }, + }) + return { ok: true, treeId: invitation.treeId } as const + }) +} + +// Connexion OIDC avec un email vérifié par le fournisseur : les invitations en attente à cette adresse sont +// acceptées sans le lien. Jamais pour un email non vérifié, jamais pour une connexion par mot de passe. +export async function claimInvitationsForVerifiedEmail(prisma: PrismaClient, userId: string, verifiedEmail: string): Promise { + const email = normalizeEmail(verifiedEmail) + const invitations = await prisma.treeInvitation.findMany({ where: { email, ...pendingInvitationWhere() }, select: { id: true } }) + const joined: string[] = [] + for (const invitation of invitations) { + const result = await acceptInvitation(prisma, invitation.id, userId, email) + if (result.ok) joined.push(result.treeId) + } + return joined +} + +// « jacques@exemple.fr » devient « ja•••@exemple.fr » sur la page publique du lien +export function maskEmail(email: string): string { + const [local, domain] = email.split('@') + if (!domain) return '•••' + return `${local.slice(0, Math.min(2, local.length))}•••@${domain}` +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 1499d9c..b8c21fd 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -6,6 +6,7 @@ import { env } from '../config/env.js' import { confirmsWithPassword, hashPassword, verifyPassword } from '../lib/auth.js' import { isOidcEnabled } from '../lib/oidc.js' import { createAccountIfAllowed, isRegistrationOpen } from '../lib/registration.js' +import { acceptInvitation, findPendingInvitationByToken } from '../lib/tree-invitations.js' import { signUserSession } from '../lib/user-session.js' import { revokeUserToken } from '../lib/token-revocation.js' import { deletedAccountVisitorKey } from '../lib/tree-visits.js' @@ -27,6 +28,8 @@ const credentialsSchema = z.object({ // Prénom du compte, facultatif : seulement à l'inscription (la connexion ne le demande pas) const registerSchema = credentialsSchema.extend({ firstName: z.string().max(60).optional().nullable(), + // Lien d'invitation à co-gérer un arbre : ouvre un compte même inscription fermée, à l'adresse invitée seulement + invitationToken: z.string().min(1).max(200).optional(), }) function trimmedFirstName(value: string | null | undefined): string | null { @@ -134,12 +137,20 @@ export const authRoutes: FastifyPluginAsync = async (app) => { return reply.code(403).send({ error: 'password_login_disabled' }) } + const email = parsed.data.email.trim().toLowerCase() + const invitation = parsed.data.invitationToken ? await findPendingInvitationByToken(app.prisma, parsed.data.invitationToken) : null + if (parsed.data.invitationToken && !invitation) { + return reply.code(410).send({ error: 'invitation_invalid' }) + } + if (invitation && invitation.email !== email) { + return reply.code(403).send({ error: 'invitation_email_mismatch' }) + } + // Inscription fermée : refusée avant toute lecture, la réponse ne dit pas si l'adresse a un compte - if (!(await isRegistrationOpen(app.prisma))) { + if (!invitation && !(await isRegistrationOpen(app.prisma))) { return reply.code(403).send({ error: 'registration_closed' }) } - const email = parsed.data.email.trim().toLowerCase() const existing = await app.prisma.user.findUnique({ where: { email } }) if (existing) { return reply.code(409).send({ error: 'email_already_exists' }) @@ -157,13 +168,20 @@ export const authRoutes: FastifyPluginAsync = async (app) => { email: true, firstName: true, }, - })) + }), { invited: Boolean(invitation) }) // Un autre compte est né pendant le calcul du hash, sur une instance fermée if (!user) { return reply.code(403).send({ error: 'registration_closed' }) } - return reply.code(201).send({ token: signUserSession(app, user), user: { ...user, hasPassword: true } }) + // Compte ouvert par une invitation : co-gestionnaire de l'arbre tout de suite + let joinedTreeId: string | null = null + if (invitation) { + const accepted = await acceptInvitation(app.prisma, invitation.id, user.id, user.email) + if (accepted.ok) joinedTreeId = accepted.treeId + } + + return reply.code(201).send({ token: signUserSession(app, user), user: { ...user, hasPassword: true }, joinedTreeId }) }, ) diff --git a/backend/src/routes/managers.ts b/backend/src/routes/managers.ts new file mode 100644 index 0000000..a627f77 --- /dev/null +++ b/backend/src/routes/managers.ts @@ -0,0 +1,221 @@ +import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify' +import { z } from 'zod' + +import { env } from '../config/env.js' +import { isOidcEnabled } from '../lib/oidc.js' +import { + INVITATION_TTL_MS, + MAX_PENDING_INVITATIONS, + acceptInvitation, + findPendingInvitationByToken, + maskEmail, + newInvitationToken, + normalizeEmail, + pendingInvitationWhere, +} from '../lib/tree-invitations.js' + +// Co-gestion d'un arbre (issue #145). Un co-gestionnaire est un membre « admin » : il fait tout ce que fait le +// propriétaire, sauf inviter ou retirer un co-gestionnaire et supprimer l'arbre (DELETE /trees/:id, réservé au +// propriétaire). Les droits sont relus en base à chaque requête : un co-gestionnaire retiré perd la main aussitôt. + +const treeIdParamSchema = z.object({ id: z.string().min(1) }) +const invitationParamsSchema = z.object({ id: z.string().min(1), invitationId: z.string().min(1) }) +const managerParamsSchema = z.object({ id: z.string().min(1), userId: z.string().min(1) }) +const tokenParamSchema = z.object({ token: z.string().min(1).max(200) }) +const inviteSchema = z.object({ email: z.string().trim().email().max(254) }) + +function requireUser(request: FastifyRequest, reply: FastifyReply): { userId: string } | null { + if (!request.actor || request.actor.kind !== 'user') { + reply.code(401).send({ error: 'authentication_required' }) + return null + } + return { userId: request.actor.userId } +} + +async function membershipOf(app: FastifyInstance, treeId: string, userId: string) { + const membership = await app.prisma.treeMembership.findUnique({ + where: { treeId_userId: { treeId, userId } }, + select: { role: true, tree: { select: { deletedAt: true, ownerUserId: true } } }, + }) + return membership && !membership.tree.deletedAt ? membership : null +} + +function audit(app: FastifyInstance, treeId: string, actorId: string, action: string, payloadJson: Record) { + return app.prisma.auditLog.create({ + data: { treeId, actorType: 'user', actorId, action, entityType: 'tree', entityId: treeId, payloadJson }, + }) +} + +export const managerRoutes: FastifyPluginAsync = async (app) => { + // Gestionnaires et invitations en attente, pour tout gestionnaire de l'arbre. canManage : le propriétaire. + app.get('/trees/:id/managers', 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 membershipOf(app, params.data.id, user.userId) + if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const [managers, invitations] = await Promise.all([ + app.prisma.treeMembership.findMany({ + where: { treeId: params.data.id, role: { in: ['owner', 'admin'] } }, + select: { role: true, createdAt: true, user: { select: { id: true, email: true, firstName: true } } }, + orderBy: { createdAt: 'asc' }, + }), + app.prisma.treeInvitation.findMany({ + where: { treeId: params.data.id, ...pendingInvitationWhere() }, + select: { id: true, email: true, createdAt: true, expiresAt: true }, + orderBy: { createdAt: 'asc' }, + }), + ]) + + return reply.send({ + canManage: membership.role === 'owner', + managers: managers.map((m) => ({ userId: m.user.id, email: m.user.email, firstName: m.user.firstName, role: m.role, since: m.createdAt })), + invitations, + }) + }) + + // Inviter une adresse. Une invitation déjà en attente pour cette adresse reçoit un nouveau lien (l'ancien ne + // sert plus). Le jeton n'est rendu qu'ici et par /link : la base n'en garde que l'empreinte. + app.post('/trees/:id/invitations', { config: { rateLimit: { max: 20, timeWindow: '10 minutes' } } }, 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 = inviteSchema.safeParse(request.body) + if (!payload.success) return reply.code(400).send({ error: 'invalid_email' }) + + const membership = await membershipOf(app, params.data.id, user.userId) + if (!membership || membership.role !== 'owner') return reply.code(403).send({ error: 'forbidden' }) + + const email = normalizeEmail(payload.data.email) + const alreadyManager = await app.prisma.treeMembership.findFirst({ + where: { treeId: params.data.id, role: { in: ['owner', 'admin'] }, user: { email } }, + select: { id: true }, + }) + if (alreadyManager) return reply.code(409).send({ error: 'already_manager' }) + + const { token, tokenHash } = newInvitationToken() + const expiresAt = new Date(Date.now() + INVITATION_TTL_MS) + const existing = await app.prisma.treeInvitation.findFirst({ + where: { treeId: params.data.id, email, ...pendingInvitationWhere() }, + select: { id: true }, + }) + let invitation + if (existing) { + invitation = await app.prisma.treeInvitation.update({ + where: { id: existing.id }, + data: { tokenHash, expiresAt }, + select: { id: true, email: true, createdAt: true, expiresAt: true }, + }) + } else { + const pending = await app.prisma.treeInvitation.count({ where: { treeId: params.data.id, ...pendingInvitationWhere() } }) + if (pending >= MAX_PENDING_INVITATIONS) return reply.code(429).send({ error: 'too_many_invitations' }) + invitation = await app.prisma.treeInvitation.create({ + data: { treeId: params.data.id, email, tokenHash, expiresAt, invitedByUserId: user.userId }, + select: { id: true, email: true, createdAt: true, expiresAt: true }, + }) + } + await audit(app, params.data.id, user.userId, 'manager_invited', { invitationId: invitation.id }) + return reply.code(201).send({ invitation, token }) + }) + + // « Copier le lien » : un nouveau jeton (l'ancien lien ne sert plus), la durée repart de zéro + app.post('/trees/:id/invitations/:invitationId/link', { config: { rateLimit: { max: 30, timeWindow: '10 minutes' } } }, async (request, reply) => { + const user = requireUser(request, reply) + if (!user) return + const params = invitationParamsSchema.safeParse(request.params) + if (!params.success) return reply.code(400).send({ error: 'invalid_params' }) + + const membership = await membershipOf(app, params.data.id, user.userId) + if (!membership || membership.role !== 'owner') return reply.code(403).send({ error: 'forbidden' }) + + const { token, tokenHash } = newInvitationToken() + const updated = await app.prisma.treeInvitation.updateMany({ + where: { id: params.data.invitationId, treeId: params.data.id, ...pendingInvitationWhere() }, + data: { tokenHash, expiresAt: new Date(Date.now() + INVITATION_TTL_MS) }, + }) + if (updated.count !== 1) return reply.code(404).send({ error: 'invitation_not_found' }) + return reply.send({ token }) + }) + + app.delete('/trees/:id/invitations/:invitationId', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) return + const params = invitationParamsSchema.safeParse(request.params) + if (!params.success) return reply.code(400).send({ error: 'invalid_params' }) + + const membership = await membershipOf(app, params.data.id, user.userId) + if (!membership || membership.role !== 'owner') return reply.code(403).send({ error: 'forbidden' }) + + const revoked = await app.prisma.treeInvitation.updateMany({ + where: { id: params.data.invitationId, treeId: params.data.id, ...pendingInvitationWhere() }, + data: { revokedAt: new Date() }, + }) + if (revoked.count !== 1) return reply.code(404).send({ error: 'invitation_not_found' }) + await audit(app, params.data.id, user.userId, 'manager_invitation_revoked', { invitationId: params.data.invitationId }) + return reply.send({ ok: true }) + }) + + // Retirer un co-gestionnaire (le propriétaire), ou quitter la gestion (le co-gestionnaire lui-même). + // Le propriétaire ne se retire jamais : son arbre part avec lui (suppression de l'arbre ou du compte). + app.delete('/trees/:id/managers/:userId', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) return + const params = managerParamsSchema.safeParse(request.params) + if (!params.success) return reply.code(400).send({ error: 'invalid_params' }) + + const membership = await membershipOf(app, params.data.id, user.userId) + const leaving = params.data.userId === user.userId + if (!membership || (leaving ? membership.role !== 'admin' : membership.role !== 'owner')) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const removed = await app.prisma.treeMembership.deleteMany({ + where: { treeId: params.data.id, userId: params.data.userId, role: 'admin' }, + }) + if (removed.count !== 1) return reply.code(404).send({ error: 'manager_not_found' }) + await audit(app, params.data.id, user.userId, leaving ? 'manager_left' : 'manager_removed', { userId: params.data.userId }) + return reply.send({ ok: true }) + }) + + // Page du lien, sans compte : l'arbre, qui invite, l'adresse masquée, et les façons d'entrer sur ce serveur + app.get('/invitations/:token', { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (request, reply) => { + 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' }) + return reply.send({ + treeName: invitation.tree.name, + invitedBy: invitation.tree.owner.firstName || null, + emailHint: maskEmail(invitation.email), + expiresAt: invitation.expiresAt, + passwordLogin: env.PASSWORD_LOGIN, + oidc: isOidcEnabled() ? { name: env.OIDC_PROVIDER_NAME } : null, + }) + }) + + // Accepter avec le compte connecté : il doit avoir l'adresse invitée + app.post('/invitations/:token/accept', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => { + const user = requireUser(request, reply) + if (!user) return + 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' }) + const account = await app.prisma.user.findUnique({ where: { id: user.userId }, select: { email: true } }) + if (!account) return reply.code(401).send({ error: 'authentication_required' }) + + const result = await acceptInvitation(app.prisma, invitation.id, user.userId, account.email) + if (!result.ok) { + const code = result.error === 'invitation_invalid' ? 404 : result.error === 'already_owner' ? 409 : 403 + return reply.code(code).send({ error: result.error }) + } + return reply.send({ treeId: result.treeId, role: 'admin' }) + }) +} diff --git a/backend/src/routes/oidc.ts b/backend/src/routes/oidc.ts index ef12b63..b12d023 100644 --- a/backend/src/routes/oidc.ts +++ b/backend/src/routes/oidc.ts @@ -15,6 +15,7 @@ import { } from '../lib/oidc.js' import { confirmsWithPassword } from '../lib/auth.js' import { createAccountIfAllowed } from '../lib/registration.js' +import { claimInvitationsForVerifiedEmail, hasPendingInvitationForEmail } from '../lib/tree-invitations.js' import { signUserSession } from '../lib/user-session.js' // Parcours : /auth/oidc/start pose un cookie signé (state, nonce, vérificateur PKCE) et part chez le fournisseur ; @@ -86,6 +87,7 @@ async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Pro return { userId: existing.id } } + // Une invitation à co-gérer un arbre ouvre un compte même inscription fermée, si le fournisseur garantit l'adresse const created = await createAccountIfAllowed(app.prisma, (tx) => tx.user.create({ data: { email: identity.email as string, @@ -94,7 +96,7 @@ async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Pro identities: { create: { issuer: identity.issuer, subject: identity.subject } }, }, select: { id: true }, - })) + }), { invited: identity.emailVerified && await hasPendingInvitationForEmail(app.prisma, identity.email) }) return created ? { userId: created.id } : { error: 'registration_closed' } } @@ -179,6 +181,10 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => { if ('error' in result) { return redirectToApp(reply, returnTo, `oidc_error=${result.error}`) } + // Invitations à co-gérer un arbre : acceptées sans le lien, l'adresse étant garantie par le fournisseur + if (identity.email && identity.emailVerified) { + await claimInvitationsForVerifiedEmail(app.prisma, result.userId, identity.email) + } const handoff = app.jwt.sign( { kind: 'oidc_handoff', sub: result.userId, jti: randomUUID() }, diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index 41da55b..b3f4009 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -448,14 +448,29 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { role: string accessMode: 'member' | 'share' lastAccessedAt: Date | null + coManagers?: string[] }> = [] + // Co-gestionnaires des arbres possédés : l'écran de suppression du compte les nomme (l'arbre part avec le compte) + const ownedIds = memberships.filter((m: { role: string }) => m.role === 'owner').map((m: { tree: { id: string } }) => m.tree.id) + const coManagerRows = ownedIds.length + ? await app.prisma.treeMembership.findMany({ + where: { treeId: { in: ownedIds }, role: 'admin' }, + select: { treeId: true, user: { select: { firstName: true, email: true } } }, + }) + : [] + for (const membership of memberships) { mergedTrees.push({ ...membership.tree, role: membership.role, accessMode: 'member', lastAccessedAt: null, + ...(membership.role === 'owner' ? { + coManagers: coManagerRows + .filter((row: { treeId: string }) => row.treeId === membership.tree.id) + .map((row: { user: { firstName: string | null; email: string } }) => row.user.firstName || row.user.email), + } : {}), }) } diff --git a/backend/test/co-gestion.db.test.ts b/backend/test/co-gestion.db.test.ts new file mode 100644 index 0000000..abfb9d7 --- /dev/null +++ b/backend/test/co-gestion.db.test.ts @@ -0,0 +1,225 @@ +import type { FastifyInstance } from 'fastify' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +// Co-gestion d'un arbre (#145), sur une vraie base : une fonction de droits se vérifie avec de vraies requêtes, +// pas avec des mocks. Lancé seulement avec TEST_DATABASE_URL (base jetable, migrée) : +// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/co-gestion.db.test.ts +const TEST_DB = process.env.TEST_DATABASE_URL + +const { oidcMock } = vi.hoisted(() => ({ oidcMock: { startAuthorization: vi.fn(), finishAuthorization: vi.fn() } })) +vi.mock('../src/lib/oidc.js', async (importOriginal) => ({ + ...(await importOriginal()), + ...oidcMock, +})) + +const ISSUER = 'https://id.example.org' +let app: FastifyInstance +let env: { ALLOW_REGISTRATION: boolean; PASSWORD_LOGIN: boolean } +let prisma: typeof import('../src/lib/prisma.js')['prisma'] +let ip = 0 +const nextIp = () => `10.1.${Math.floor(++ip / 250)}.${ip % 250 + 1}` +const run = Date.now().toString(36) +const mail = (name: string) => `${name}-${run}@exemple.fr` + +async function call(method: string, url: string, token?: string, payload?: unknown) { + const res = await app.inject({ + method: method as 'GET', + url, + payload: payload as Record, + headers: token ? { authorization: `Bearer ${token}` } : {}, + remoteAddress: nextIp(), + }) + return { status: res.statusCode, body: res.body ? res.json() : null } +} + +async function register(name: string, extra: Record = {}) { + const res = await call('POST', '/auth/register', undefined, { email: mail(name), password: 'mot-de-passe-solide', firstName: name, ...extra }) + return res +} + +async function account(name: string) { + const res = await register(name) + expect(res.status).toBe(201) + return { token: res.body.token as string, id: res.body.user.id as string, email: mail(name) } +} + +describe.skipIf(!TEST_DB)('co-gestion, vraie base', () => { + let owner: { token: string; id: string; email: string } + let treeId: string + + beforeAll(async () => { + process.env.NODE_ENV = 'test' + process.env.DATABASE_URL = TEST_DB + process.env.JWT_SECRET = 'test-secret-with-sufficient-length' + process.env.CORS_ORIGIN = 'http://localhost:5173' + process.env.OIDC_ISSUER = ISSUER + process.env.OIDC_CLIENT_ID = 'parenthese' + process.env.OIDC_CLIENT_SECRET = 'secret' + process.env.OIDC_REDIRECT_URI = 'https://arbre.example.org/auth/oidc/callback' + ;({ env } = await import('../src/config/env.js')) + ;({ prisma } = await import('../src/lib/prisma.js')) + const { createApp } = await import('../src/app.js') + app = createApp() + env.ALLOW_REGISTRATION = true + owner = await account('proprio') + const tree = await call('POST', '/trees', owner.token, { name: 'Arbre de démo', slug: `demo-${run}`, visitorPassword: 'partage-demo-1', contributorPassword: 'partage-demo-1' }) + expect(tree.status).toBe(201) + treeId = tree.body.tree?.id ?? tree.body.id + }) + + beforeEach(() => { + env.ALLOW_REGISTRATION = true + env.PASSWORD_LOGIN = true + }) + + afterAll(async () => { + await app?.close() + }) + + async function invite(email: string) { + const res = await call('POST', `/trees/${treeId}/invitations`, owner.token, { email }) + expect(res.status).toBe(201) + return res.body as { token: string; invitation: { id: string } } + } + + it("un compte sans lien avec l'arbre ne voit ni ne change les gestionnaires", async () => { + const stranger = await account('inconnu') + expect((await call('GET', `/trees/${treeId}/managers`, stranger.token)).status).toBe(403) + expect((await call('POST', `/trees/${treeId}/invitations`, stranger.token, { email: stranger.email })).status).toBe(403) + expect((await call('DELETE', `/trees/${treeId}/managers/${owner.id}`, stranger.token)).status).toBe(403) + expect((await call('GET', `/trees/${treeId}/managers`)).status).toBe(401) + }) + + it("le lien n'ouvre rien au mauvais compte, ne sert qu'une fois, et donne les droits d'administration", async () => { + const coEmail = mail('cogest') + const { token, invitation } = await invite(coEmail) + + const preview = await call('GET', `/invitations/${token}`) + expect(preview.status).toBe(200) + expect(preview.body.treeName).toBe('Arbre de démo') + expect(preview.body.emailHint).not.toContain(coEmail.split('@')[0]) + + // Le lien entre de mauvaises mains : un compte à une autre adresse ne l'accepte pas + const thief = await account('voleur') + expect((await call('POST', `/invitations/${token}/accept`, thief.token)).body.error).toBe('invitation_email_mismatch') + expect((await call('PATCH', `/trees/${treeId}`, thief.token, { name: 'Volé' })).status).toBe(403) + + const co = await account('cogest') + const accepted = await call('POST', `/invitations/${token}/accept`, co.token) + 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 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 + expect((await call('PATCH', `/trees/${treeId}`, co.token, { description: 'Par la co-gestionnaire' })).status).toBe(200) + const list = await call('GET', `/trees/${treeId}/managers`, co.token) + expect(list.body.canManage).toBe(false) + expect(list.body.managers.map((m: { role: string }) => m.role).sort()).toEqual(['admin', 'owner']) + expect((await call('POST', `/trees/${treeId}/invitations`, co.token, { email: mail('autre') })).status).toBe(403) + expect((await call('DELETE', `/trees/${treeId}/managers/${owner.id}`, co.token)).status).toBe(403) + expect((await call('DELETE', `/trees/${treeId}`, co.token, { confirmName: 'Arbre de démo' })).status).toBe(403) + + // Le propriétaire le retire : son jeton de session ne sert plus à rien sur l'arbre, tout de suite + expect((await call('DELETE', `/trees/${treeId}/managers/${co.id}`, owner.token)).status).toBe(200) + expect((await call('PATCH', `/trees/${treeId}`, co.token, { description: 'Encore moi' })).status).toBe(403) + expect((await call('GET', `/trees/${treeId}/managers`, co.token)).status).toBe(403) + }) + + it('un co-gestionnaire quitte seul, et ne retire jamais un autre co-gestionnaire', async () => { + const a = await account('gestion-a') + const b = await account('gestion-b') + for (const who of [a, b]) { + const { token } = await invite(who.email) + expect((await call('POST', `/invitations/${token}/accept`, who.token)).status).toBe(200) + } + expect((await call('DELETE', `/trees/${treeId}/managers/${b.id}`, a.token)).status).toBe(403) + expect((await call('DELETE', `/trees/${treeId}/managers/${a.id}`, a.token)).status).toBe(200) + expect((await call('PATCH', `/trees/${treeId}`, a.token, { description: 'x' })).status).toBe(403) + expect((await call('PATCH', `/trees/${treeId}`, b.token, { description: 'y' })).status).toBe(200) + // Le propriétaire ne se retire pas lui-même + expect((await call('DELETE', `/trees/${treeId}/managers/${owner.id}`, owner.token)).status).toBe(403) + await call('DELETE', `/trees/${treeId}/managers/${b.id}`, owner.token) + }) + + it('« Copier le lien » remplace le lien ; annulé ou expiré, il ne sert plus', async () => { + const email = mail('rotation') + const first = await invite(email) + const rotated = await call('POST', `/trees/${treeId}/invitations/${first.invitation.id}/link`, owner.token) + expect(rotated.status).toBe(200) + expect((await call('GET', `/invitations/${first.token}`)).status).toBe(404) + expect((await call('GET', `/invitations/${rotated.body.token}`)).status).toBe(200) + + expect((await call('DELETE', `/trees/${treeId}/invitations/${first.invitation.id}`, owner.token)).status).toBe(200) + const user = await account('rotation') + expect((await call('POST', `/invitations/${rotated.body.token}/accept`, user.token)).status).toBe(404) + + 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) + }) + + it("inscription fermée : l'invitation ouvre un compte, à l'adresse invitée seulement", async () => { + const email = mail('ferme') + const { token } = await invite(email) + env.ALLOW_REGISTRATION = false + + expect((await register('sans-invitation')).body.error).toBe('registration_closed') + expect((await register('autre-adresse', { invitationToken: token })).body.error).toBe('invitation_email_mismatch') + expect((await register('x', { invitationToken: 'faux-jeton' })).status).toBe(410) + + const created = await register('ferme', { invitationToken: token }) + expect(created.status).toBe(201) + expect(created.body.joinedTreeId).toBe(treeId) + expect((await call('PATCH', `/trees/${treeId}`, created.body.token, { description: 'z' })).status).toBe(200) + // Le lien a servi : il n'ouvre pas un second compte + expect((await call('POST', '/auth/register', undefined, { email: mail('ferme2'), password: 'mot-de-passe-solide', invitationToken: token })).status).toBe(410) + await call('DELETE', `/trees/${treeId}/managers/${created.body.user.id}`, owner.token) + }) + + describe('OIDC', () => { + let address = 0 + async function oidcLogin(identity: { subject: string; email: string; emailVerified: boolean }) { + const remoteAddress = `10.9.0.${++address}` + oidcMock.startAuthorization.mockImplementation(async (returnTo: string) => ({ + url: new URL(`${ISSUER}/authorize?state=s`), + pending: { state: 's', nonce: 'n', codeVerifier: 'v'.repeat(43), returnTo }, + })) + oidcMock.finishAuthorization.mockResolvedValue({ issuer: ISSUER, firstName: null, ...identity }) + const start = await app.inject({ method: 'GET', url: '/auth/oidc/start?returnTo=/', remoteAddress }) + const cookie = String(start.headers['set-cookie']).split(';')[0] + const back = await app.inject({ method: 'GET', url: '/auth/oidc/callback?code=c&state=s', headers: { cookie }, remoteAddress }) + return new URLSearchParams(String(back.headers.location).split('#')[1] ?? '') + } + + it("email vérifié par le fournisseur : rattaché sans le lien, même inscription fermée", async () => { + const email = mail('oidc-ok') + await invite(email) + env.ALLOW_REGISTRATION = false + const fragment = await oidcLogin({ subject: `sub-ok-${run}`, email, emailVerified: true }) + expect(fragment.get('oidc')).toBeTruthy() + const user = await prisma.user.findUnique({ where: { email }, select: { id: true } }) + const membership = await prisma.treeMembership.findUnique({ where: { treeId_userId: { treeId, userId: user!.id } } }) + expect(membership?.role).toBe('admin') + }) + + it("email non vérifié : jamais rattaché, et pas de compte sur une instance fermée", async () => { + const email = mail('oidc-non-verifie') + await invite(email) + env.ALLOW_REGISTRATION = false + const fragment = await oidcLogin({ subject: `sub-nv-${run}`, email, emailVerified: false }) + expect(fragment.get('oidc_error')).toBe('oidc_email_unverified') + expect(await prisma.user.findUnique({ where: { email } })).toBeNull() + }) + + it("email non vérifié sur un compte déjà relié : l'invitation reste en attente", async () => { + const email = mail('oidc-relie') + const subject = `sub-relie-${run}` + expect((await oidcLogin({ subject, email, emailVerified: true })).get('oidc')).toBeTruthy() + await invite(email) + expect((await oidcLogin({ subject, email, emailVerified: false })).get('oidc')).toBeTruthy() + const user = await prisma.user.findUnique({ where: { email }, select: { id: true } }) + expect(await prisma.treeMembership.findUnique({ where: { treeId_userId: { treeId, userId: user!.id } } })).toBeNull() + }) + }) +}) diff --git a/backend/test/oidc.test.ts b/backend/test/oidc.test.ts index ca4c795..9432191 100644 --- a/backend/test/oidc.test.ts +++ b/backend/test/oidc.test.ts @@ -15,6 +15,10 @@ const { prismaMock, oidcMock } = vi.hoisted(() => ({ findUnique: vi.fn(), create: vi.fn(), }, + treeInvitation: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, $transaction: vi.fn(), $executeRaw: vi.fn(), $disconnect: vi.fn(), @@ -71,6 +75,8 @@ beforeEach(() => { prismaMock.user.count.mockResolvedValue(1) prismaMock.user.findUnique.mockResolvedValue(null) prismaMock.userIdentity.findUnique.mockResolvedValue(null) + prismaMock.treeInvitation.findFirst.mockResolvedValue(null) + prismaMock.treeInvitation.findMany.mockResolvedValue([]) prismaMock.userIdentity.create.mockResolvedValue({}) prismaMock.user.create.mockResolvedValue({ id: 'user-new' }) prismaMock.$transaction.mockImplementation(async (run: (tx: typeof prismaMock) => unknown) => run(prismaMock)) From ac9c33d43f1c55ebff3efd0ee9f5f78f490a8d38 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sun, 25 Oct 2026 16:50:44 +0100 Subject: [PATCH 2/6] =?UTF-8?q?Co-gestion=20(#145),=20revue=20de=20droits?= =?UTF-8?q?=20:=20Immich=20r=C3=A9serv=C3=A9=20au=20propri=C3=A9taire,=20r?= =?UTF-8?q?etrait=20qui=20coupe=20aussi=20l'acc=C3=A8s=20par=20le=20partag?= =?UTF-8?q?e,=20rattachement=20OIDC=20sur=20l'adresse=20du=20compte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- backend/src/lib/tree-invitations.ts | 7 ++++- backend/src/routes/auth.ts | 8 ++++- backend/src/routes/immich.ts | 47 +++++++++++++++++++++-------- backend/src/routes/managers.ts | 16 ++++++++-- backend/src/routes/oidc.ts | 3 +- backend/test/co-gestion.db.test.ts | 36 ++++++++++++++++++++++ 6 files changed, 98 insertions(+), 19 deletions(-) diff --git a/backend/src/lib/tree-invitations.ts b/backend/src/lib/tree-invitations.ts index d96ac70..4c4a986 100644 --- a/backend/src/lib/tree-invitations.ts +++ b/backend/src/lib/tree-invitations.ts @@ -96,10 +96,15 @@ export async function acceptInvitation(prisma: PrismaClient, invitationId: strin } // Connexion OIDC avec un email vérifié par le fournisseur : les invitations en attente à cette adresse sont -// acceptées sans le lien. Jamais pour un email non vérifié, jamais pour une connexion par mot de passe. +// acceptées sans le lien. Jamais pour un email non vérifié, jamais pour une connexion par mot de passe, et +// seulement si c'est l'adresse du compte : une identité déjà reliée dont l'email a changé chez le fournisseur +// ne prend pas les invitations faites à sa nouvelle adresse (elle passe par le lien). export async function claimInvitationsForVerifiedEmail(prisma: PrismaClient, userId: string, verifiedEmail: string): Promise { const email = normalizeEmail(verifiedEmail) const invitations = await prisma.treeInvitation.findMany({ where: { email, ...pendingInvitationWhere() }, select: { id: true } }) + if (!invitations.length) return [] + const account = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } }) + if (!account || normalizeEmail(account.email) !== email) return [] const joined: string[] = [] for (const invitation of invitations) { const result = await acceptInvitation(prisma, invitation.id, userId, email) diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index b8c21fd..95ef14b 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -178,7 +178,13 @@ export const authRoutes: FastifyPluginAsync = async (app) => { let joinedTreeId: string | null = null if (invitation) { const accepted = await acceptInvitation(app.prisma, invitation.id, user.id, user.email) - if (accepted.ok) joinedTreeId = accepted.treeId + if (accepted.ok) { + joinedTreeId = accepted.treeId + } else if (!(await isRegistrationOpen(app.prisma))) { + // Invitation annulée ou servie entre-temps, sur une instance fermée : pas de compte sans arbre + await app.prisma.user.delete({ where: { id: user.id } }) + return reply.code(410).send({ error: 'invitation_invalid' }) + } } return reply.code(201).send({ token: signUserSession(app, user), user: { ...user, hasPassword: true }, joinedTreeId }) diff --git a/backend/src/routes/immich.ts b/backend/src/routes/immich.ts index 9b8c227..1383c55 100644 --- a/backend/src/routes/immich.ts +++ b/backend/src/routes/immich.ts @@ -1,5 +1,5 @@ import { Prisma } from '@prisma/client' -import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify' +import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify' import path from 'node:path' import { randomUUID } from 'node:crypto' import { mkdir, writeFile } from 'node:fs/promises' @@ -18,7 +18,7 @@ import { } from '../lib/immich.js' import { decryptImmichApiKey, encryptImmichApiKey } from '../lib/immich-secret.js' import { IMAGE_MIME_TYPES, InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js' -import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile, requireAdminMembership } from './media.js' +import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile } from './media.js' // Reconnaissance des visages : associer une personne de l'arbre à une personne Immich, dont la // vignette devient son portrait. Chaque arbre porte sa propre connexion Immich : une adresse @@ -97,6 +97,21 @@ function buildAvatarRelativePath(treeId: string, personId: string, extension: st return path.posix.join(treeId, personId, `immich-${randomUUID()}${extension}`) } + +async function membershipRole(app: FastifyInstance, userId: string, treeId: string) { + const membership = await app.prisma.treeMembership.findUnique({ + where: { treeId_userId: { treeId, userId } }, + select: { role: true, tree: { select: { deletedAt: true } } }, + }) + return membership && !membership.tree.deletedAt ? membership.role : null +} + +// Immich passe par la clé du propriétaire, qui ouvre toute sa photothèque, pas seulement les photos de l'arbre : +// réservé au propriétaire, jamais aux co-gestionnaires (issue #145) +async function requireImmichOwner(app: FastifyInstance, userId: string, treeId: string): Promise { + return (await membershipRole(app, userId, treeId)) === 'owner' +} + export const immichRoutes: FastifyPluginAsync = async (app) => { // Lecture réservée aux administrateurs : la clé donne accès à la photothèque de l'arbre. // Les fichiers sélectionnés passent ensuite par l'envoi de souvenirs habituel (quota, @@ -111,7 +126,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { personId: z.string().refine(isImmichPersonId).optional(), }).safeParse(request.query) if (!params.success || !query.success) return reply.code(400).send({ error: 'invalid_params' }) - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' }) + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' }) const client = await getTreeImmichClient(app.prisma, params.data.id) if (!client) return reply.code(503).send({ error: 'immich_not_configured' }) try { @@ -124,7 +139,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { if (request.actor?.kind !== 'user') return reply.code(401).send({ error: 'authentication_required' }) const params = z.object({ id: z.string().min(1), assetId: z.string().refine(isImmichPersonId), variant: z.enum(['thumbnail', 'preview', 'original']) }).safeParse(request.params) if (!params.success) return reply.code(400).send({ error: 'invalid_params' }) - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' }) + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' }) const client = await getTreeImmichClient(app.prisma, params.data.id) if (!client) return reply.code(503).send({ error: 'immich_not_configured' }) try { @@ -154,9 +169,15 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_params' }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + const role = await membershipRole(app, request.actor.userId, params.data.id) + if (role !== 'owner' && role !== 'admin') { return reply.code(403).send({ error: 'forbidden' }) } + // Co-gestionnaire : la clé Immich est celle du propriétaire et ouvre toute sa photothèque. Il ne voit ni ne + // règle rien ; pour l'interface, Immich n'est pas configuré + if (role !== 'owner') { + return reply.send({ config: null, instances: [], ownerOnly: true }) + } const config = await app.prisma.treeImmichConfig.findUnique({ where: { treeId: params.data.id }, @@ -184,7 +205,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) { return reply.code(403).send({ error: 'forbidden' }) } @@ -253,7 +274,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_params' }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) { return reply.code(403).send({ error: 'forbidden' }) } @@ -287,7 +308,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_params' }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) { return reply.code(403).send({ error: 'forbidden' }) } @@ -310,7 +331,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_params' }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) { return reply.code(403).send({ error: 'forbidden' }) } @@ -339,7 +360,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_immich_person_id' }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) { return reply.code(403).send({ error: 'forbidden' }) } @@ -381,7 +402,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_params' }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) { return reply.code(403).send({ error: 'forbidden' }) } @@ -407,7 +428,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: payload.success ? 'invalid_params' : 'invalid_immich_person_id' }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) { return reply.code(403).send({ error: 'forbidden' }) } @@ -565,7 +586,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_params' }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) { return reply.code(403).send({ error: 'forbidden' }) } diff --git a/backend/src/routes/managers.ts b/backend/src/routes/managers.ts index a627f77..ee038be 100644 --- a/backend/src/routes/managers.ts +++ b/backend/src/routes/managers.ts @@ -16,7 +16,9 @@ import { // Co-gestion d'un arbre (issue #145). Un co-gestionnaire est un membre « admin » : il fait tout ce que fait le // propriétaire, sauf inviter ou retirer un co-gestionnaire et supprimer l'arbre (DELETE /trees/:id, réservé au -// propriétaire). Les droits sont relus en base à chaque requête : un co-gestionnaire retiré perd la main aussitôt. +// propriétaire), ni Immich, qui passe par la clé du propriétaire (routes/immich.ts). Les droits sont relus en base à +// chaque requête : un co-gestionnaire retiré perd la main aussitôt. Il a pu voir le mot de passe de partage : +// l'écran de retrait propose de le changer. const treeIdParamSchema = z.object({ id: z.string().min(1) }) const invitationParamsSchema = z.object({ id: z.string().min(1), invitationId: z.string().min(1) }) @@ -175,8 +177,16 @@ export const managerRoutes: FastifyPluginAsync = async (app) => { return reply.code(403).send({ error: 'forbidden' }) } - const removed = await app.prisma.treeMembership.deleteMany({ - where: { treeId: params.data.id, userId: params.data.userId, role: 'admin' }, + // Un accès retenu par le mot de passe de partage (user_tree_accesses) part aussi : sinon le compte retiré + // lirait encore l'arbre + const removed = await app.prisma.$transaction(async (tx) => { + const deleted = await tx.treeMembership.deleteMany({ + where: { treeId: params.data.id, userId: params.data.userId, role: 'admin' }, + }) + if (deleted.count === 1) { + await tx.userTreeAccess.deleteMany({ where: { treeId: params.data.id, userId: params.data.userId } }) + } + return deleted }) if (removed.count !== 1) return reply.code(404).send({ error: 'manager_not_found' }) await audit(app, params.data.id, user.userId, leaving ? 'manager_left' : 'manager_removed', { userId: params.data.userId }) diff --git a/backend/src/routes/oidc.ts b/backend/src/routes/oidc.ts index b12d023..0f1afc2 100644 --- a/backend/src/routes/oidc.ts +++ b/backend/src/routes/oidc.ts @@ -181,7 +181,8 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => { if ('error' in result) { return redirectToApp(reply, returnTo, `oidc_error=${result.error}`) } - // Invitations à co-gérer un arbre : acceptées sans le lien, l'adresse étant garantie par le fournisseur + // Invitations à co-gérer un arbre : acceptées sans le lien, l'adresse étant garantie par le fournisseur (et + // devant être celle du compte, voir claimInvitationsForVerifiedEmail) if (identity.email && identity.emailVerified) { await claimInvitationsForVerifiedEmail(app.prisma, result.userId, identity.email) } diff --git a/backend/test/co-gestion.db.test.ts b/backend/test/co-gestion.db.test.ts index abfb9d7..9726660 100644 --- a/backend/test/co-gestion.db.test.ts +++ b/backend/test/co-gestion.db.test.ts @@ -177,6 +177,32 @@ describe.skipIf(!TEST_DB)('co-gestion, vraie base', () => { await call('DELETE', `/trees/${treeId}/managers/${created.body.user.id}`, owner.token) }) + it("un co-gestionnaire n'utilise jamais Immich, qui passe par la clé du propriétaire", async () => { + const co = await account('immich') + const { token } = await invite(co.email) + expect((await call('POST', `/invitations/${token}/accept`, co.token)).status).toBe(200) + const config = await call('GET', `/trees/${treeId}/immich/config`, co.token) + expect(config.status).toBe(200) + expect(config.body).toEqual({ config: null, instances: [], ownerOnly: true }) + for (const url of ['/immich/assets?page=1&q=plage', '/immich/people', '/immich/status', '/immich/assets/3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b/thumbnail', '/immich/thumbnail/3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b']) { + expect((await call('GET', `/trees/${treeId}${url}`, co.token)).status).toBe(403) + } + expect((await call('PUT', `/trees/${treeId}/immich/config`, co.token, { instanceId: '0123456789abcdef', apiKey: 'y' })).status).toBe(403) + expect((await call('DELETE', `/trees/${treeId}/immich/config`, co.token)).status).toBe(403) + await call('DELETE', `/trees/${treeId}/managers/${co.id}`, owner.token) + }) + + it("retiré, un co-gestionnaire qui avait ouvert l'arbre par le mot de passe de partage ne le lit plus", async () => { + const co = await account('partage') + const unlocked = await call('POST', `/trees/${treeId}/access/unlock`, co.token, { password: 'partage-demo-1' }) + expect(unlocked.status).toBe(200) + expect((await call('GET', `/trees/${treeId}/graph`, co.token)).status).toBe(200) + const { token } = await invite(co.email) + expect((await call('POST', `/invitations/${token}/accept`, co.token)).status).toBe(200) + expect((await call('DELETE', `/trees/${treeId}/managers/${co.id}`, owner.token)).status).toBe(200) + expect((await call('GET', `/trees/${treeId}/graph`, co.token)).status).toBe(403) + }) + describe('OIDC', () => { let address = 0 async function oidcLogin(identity: { subject: string; email: string; emailVerified: boolean }) { @@ -212,6 +238,16 @@ describe.skipIf(!TEST_DB)('co-gestion, vraie base', () => { expect(await prisma.user.findUnique({ where: { email } })).toBeNull() }) + it("identité déjà reliée dont l'email a changé chez le fournisseur : pas d'invitation prise sans le lien", async () => { + const subject = `sub-change-${run}` + expect((await oidcLogin({ subject, email: mail('avant'), emailVerified: true })).get('oidc')).toBeTruthy() + const target = mail('cible') + await invite(target) + expect((await oidcLogin({ subject, email: target, emailVerified: true })).get('oidc')).toBeTruthy() + const user = await prisma.user.findUnique({ where: { email: mail('avant') }, select: { id: true } }) + expect(await prisma.treeMembership.findUnique({ where: { treeId_userId: { treeId, userId: user!.id } } })).toBeNull() + }) + it("email non vérifié sur un compte déjà relié : l'invitation reste en attente", async () => { const email = mail('oidc-relie') const subject = `sub-relie-${run}` From 07d69896ae997339852ba2e427285e2abfe4a027 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sun, 25 Oct 2026 16:57:57 +0100 Subject: [PATCH 3/6] =?UTF-8?q?Co-gestion=20(#145),=20=C3=A9crans=20:=20on?= =?UTF-8?q?glet=20Gestion,=20page=20du=20lien=20d'invitation,=20Quitter=20?= =?UTF-8?q?pour=20le=20co-gestionnaire,=20avertissement=20=C3=A0=20la=20su?= =?UTF-8?q?ppression=20du=20compte,=20Immich=20cach=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- backend/src/routes/managers.ts | 11 +- backend/test/co-gestion.db.test.ts | 3 + frontend/src/App.jsx | 33 +++ frontend/src/api/authApi.js | 5 +- frontend/src/api/managersApi.js | 40 ++++ frontend/src/components/AccountDashboard.jsx | 13 ++ frontend/src/components/AccountScreens.css | 1 + frontend/src/components/AdminPanel.jsx | 50 +++- frontend/src/components/InvitationScreen.jsx | 175 ++++++++++++++ frontend/src/components/SettingsPanels.css | 13 ++ .../src/components/TreeManagersSection.jsx | 221 ++++++++++++++++++ frontend/src/components/coGestion.test.jsx | 34 +++ .../src/components/navbar/TreeDropdown.jsx | 2 +- frontend/src/hooks/useAuth.js | 4 +- frontend/src/i18n/en/gestion.js | 73 ++++++ frontend/src/i18n/en/index.js | 2 + frontend/src/utils/errorMessages.js | 13 ++ 17 files changed, 686 insertions(+), 7 deletions(-) create mode 100644 frontend/src/api/managersApi.js create mode 100644 frontend/src/components/InvitationScreen.jsx create mode 100644 frontend/src/components/TreeManagersSection.jsx create mode 100644 frontend/src/components/coGestion.test.jsx create mode 100644 frontend/src/i18n/en/gestion.js diff --git a/backend/src/routes/managers.ts b/backend/src/routes/managers.ts index ee038be..1f88080 100644 --- a/backend/src/routes/managers.ts +++ b/backend/src/routes/managers.ts @@ -8,6 +8,7 @@ import { MAX_PENDING_INVITATIONS, acceptInvitation, findPendingInvitationByToken, + hashInvitationToken, maskEmail, newInvitationToken, normalizeEmail, @@ -198,7 +199,15 @@ export const managerRoutes: FastifyPluginAsync = async (app) => { const params = tokenParamSchema.safeParse(request.params) if (!params.success) return reply.code(404).send({ error: 'invitation_invalid' }) const invitation = await findPendingInvitationByToken(app.prisma, params.data.token) - if (!invitation) return reply.code(404).send({ error: 'invitation_invalid' }) + if (!invitation) { + // Lien déjà servi ou expiré : dit tel quel, pour que la page explique quoi faire. Rien d'autre ne sort. + const known = await app.prisma.treeInvitation.findUnique({ + where: { tokenHash: hashInvitationToken(params.data.token) }, + select: { acceptedAt: true, revokedAt: true, expiresAt: true }, + }) + const error = known?.acceptedAt ? 'invitation_used' : known && !known.revokedAt && known.expiresAt <= new Date() ? 'invitation_expired' : 'invitation_invalid' + return reply.code(404).send({ error }) + } return reply.send({ treeName: invitation.tree.name, invitedBy: invitation.tree.owner.firstName || null, diff --git a/backend/test/co-gestion.db.test.ts b/backend/test/co-gestion.db.test.ts index 9726660..a73b778 100644 --- a/backend/test/co-gestion.db.test.ts +++ b/backend/test/co-gestion.db.test.ts @@ -109,6 +109,7 @@ describe.skipIf(!TEST_DB)('co-gestion, vraie base', () => { expect(accepted.status).toBe(200) expect(accepted.body).toEqual({ treeId, role: 'admin' }) expect((await call('POST', `/invitations/${token}/accept`, co.token)).status).toBe(404) + expect((await call('GET', `/invitations/${token}`)).body).toEqual({ error: 'invitation_used' }) expect((await prisma.treeInvitation.findUnique({ where: { id: invitation.id } }))?.acceptedUserId).toBe(co.id) // Co-gestionnaire : modifie l'arbre, voit la liste, mais n'invite pas, ne retire personne, ne supprime pas l'arbre @@ -157,6 +158,8 @@ describe.skipIf(!TEST_DB)('co-gestion, vraie base', () => { const late = await invite(email) await prisma.treeInvitation.update({ where: { id: late.invitation.id }, data: { expiresAt: new Date(Date.now() - 1000) } }) expect((await call('POST', `/invitations/${late.token}/accept`, user.token)).status).toBe(404) + expect((await call('GET', `/invitations/${late.token}`)).body).toEqual({ error: 'invitation_expired' }) + expect((await call('GET', `/invitations/${rotated.body.token}`)).body).toEqual({ error: 'invitation_invalid' }) }) it("inscription fermée : l'invitation ouvre un compte, à l'adresse invitée seulement", async () => { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d0524cc..b4b0059 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,6 +12,7 @@ import AdminPanel from './components/AdminPanel' import AccessGate from './components/AccessGate' import EntryChoice from './components/EntryChoice' import AccountDashboard from './components/AccountDashboard' +import InvitationScreen from './components/InvitationScreen.jsx' import AccountPanel from './components/AccountPanel' import TreeCreationWizard from './components/TreeCreationWizard' import PhotoStartPanel from './components/PhotoStartPanel.jsx' @@ -487,6 +488,14 @@ function readOidcReturn() { return { code: params.get('oidc'), error: params.get('oidc_error') } } const OIDC_RETURN = readOidcReturn() + +// Lien d'invitation à co-gérer un arbre (/invitation/, issue #145) : lu une fois au chargement, la page +// d'invitation passe avant tout le reste jusqu'à ce que l'arbre s'ouvre +function readInvitationToken() { + if (typeof window === 'undefined') return null + return window.location.pathname.match(/^\/invitation\/([A-Za-z0-9_-]{20,100})$/)?.[1] ?? null +} +const INVITATION_TOKEN = readInvitationToken() let oidcSignIn = null function App() { @@ -2963,6 +2972,25 @@ function App() { } + if (INVITATION_TOKEN) { + return ( +
+ window.location.assign(oidcStartUrl(`/invitation/${INVITATION_TOKEN}`)) : undefined} + onLogout={handleLogout} + onOpenApp={() => window.location.assign('/')} + /> +
+ ) + } + if (tree.bootState === 'loading') { return (
@@ -3348,7 +3376,12 @@ function App() { treeId={tree.treeContext.treeId} authToken={tree.treeContext.accessToken} isAdmin={userRole.isAdmin} + isOwner={userRole.isOwner} + currentUserId={auth.userAuth.user?.id || null} + oidcName={authOptions.oidc?.name || ''} onTreeDeleted={handleTreeDeleted} + onLeftTree={handleTreeDeleted} + onOpenShare={() => { setAdminPanelVisible(false); handleOpenContributionPanel() }} onGedcomImported={handleGedcomImported} onOpenPhotoStart={() => { setAdminPanelVisible(false); openPhotoStart() }} onClose={() => setAdminPanelVisible(false)} diff --git a/frontend/src/api/authApi.js b/frontend/src/api/authApi.js index 8418eac..b2db1d2 100644 --- a/frontend/src/api/authApi.js +++ b/frontend/src/api/authApi.js @@ -9,10 +9,11 @@ export async function loginUser(email, password) { return data } -export async function registerUser(firstName, email, password) { +// invitationToken : lien d'invitation à co-gérer un arbre, qui ouvre un compte même inscription fermée +export async function registerUser(firstName, email, password, invitationToken) { const data = await apiRequest('/auth/register', { method: 'POST', - body: JSON.stringify({ firstName, email, password }), + body: JSON.stringify({ firstName, email, password, ...(invitationToken ? { invitationToken } : {}) }), }) return data diff --git a/frontend/src/api/managersApi.js b/frontend/src/api/managersApi.js new file mode 100644 index 0000000..79fad1a --- /dev/null +++ b/frontend/src/api/managersApi.js @@ -0,0 +1,40 @@ +import { apiRequest } from './client' + +// Co-gestion d'un arbre (issue #145) : gestionnaires, invitations, et la page du lien d'invitation + +const auth = (token) => ({ Authorization: `Bearer ${token}` }) +const tree = (treeId) => `/trees/${encodeURIComponent(treeId)}` + +export function listManagers(treeId, token) { + return apiRequest(`${tree(treeId)}/managers`, { headers: auth(token) }) +} + +export function inviteManager(treeId, email, token) { + return apiRequest(`${tree(treeId)}/invitations`, { method: 'POST', headers: auth(token), body: JSON.stringify({ email }) }) +} + +export function renewInvitationLink(treeId, invitationId, token) { + return apiRequest(`${tree(treeId)}/invitations/${encodeURIComponent(invitationId)}/link`, { method: 'POST', headers: auth(token) }) +} + +export function revokeInvitation(treeId, invitationId, token) { + return apiRequest(`${tree(treeId)}/invitations/${encodeURIComponent(invitationId)}`, { method: 'DELETE', headers: auth(token) }) +} + +// Retirer un co-gestionnaire (le propriétaire), ou quitter la gestion (son propre identifiant) +export function removeManager(treeId, userId, token) { + return apiRequest(`${tree(treeId)}/managers/${encodeURIComponent(userId)}`, { method: 'DELETE', headers: auth(token) }) +} + +// Sous /api comme /api/arbre/:slug : nginx ne relaie à l'API que /api (préfixe retiré), /auth et /trees +export function fetchInvitation(invitationToken) { + return apiRequest(`/api/invitations/${encodeURIComponent(invitationToken)}`) +} + +export function acceptInvitation(invitationToken, token) { + return apiRequest(`/api/invitations/${encodeURIComponent(invitationToken)}/accept`, { method: 'POST', headers: auth(token) }) +} + +export function invitationUrl(invitationToken) { + return `${window.location.origin}/invitation/${invitationToken}` +} diff --git a/frontend/src/components/AccountDashboard.jsx b/frontend/src/components/AccountDashboard.jsx index 5f8eb32..44c781a 100644 --- a/frontend/src/components/AccountDashboard.jsx +++ b/frontend/src/components/AccountDashboard.jsx @@ -344,6 +344,19 @@ const AccountDashboard = ({
  • {t("Les arbres partagés avec vous restent à leurs propriétaires. Vous n'y aurez simplement plus accès.")}
  • + {/* Arbres gérés avec d'autres : ils partent aussi pour leurs co-gestionnaires (issue #145) */} + {(trees || []).filter((tree) => tree.role === 'owner' && tree.coManagers?.length > 0).map((tree) => ( +
    +
    + {t('« {name} » sera supprimé pour tout le monde', { name: tree.name })} + + {tree.coManagers.length > 1 + ? t('{names} le gèrent avec vous. Ils n’y auront plus accès, la famille non plus.', { names: tree.coManagers.join(', ') }) + : t('{name} le gère avec vous. Cette personne n’y aura plus accès, la famille non plus.', { name: tree.coManagers[0] })} + +
    +
    + ))}
    diff --git a/frontend/src/components/AccountScreens.css b/frontend/src/components/AccountScreens.css index 0cac3b9..249495f 100644 --- a/frontend/src/components/AccountScreens.css +++ b/frontend/src/components/AccountScreens.css @@ -65,3 +65,4 @@ .ap-item-avatar img { width: 100%; height: 100%; object-fit: cover; } .ap-item-name { flex: 1; min-width: 0; font: 400 16px/1.2 var(--pz-serif); color: var(--pz-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ap-item-name em { font-style: italic; font-weight: 300; } +.invite-doors { display: grid; gap: 10px; } diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index 9ced67d..91047b0 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -19,6 +19,8 @@ import { } from '../api/immichApi.js' import PzBusy from './PzBusy.jsx' import TreeVisitsSection from './TreeVisitsSection.jsx' +import TreeManagersSection from './TreeManagersSection.jsx' +import { removeManager } from '../api/managersApi.js' import { t } from '../i18n/index.js' import { readGedcomFile } from '../utils/gedcomText.js' import { readGedcomBundle, zipErrorMessage } from '../utils/gedcomBundle.js' @@ -39,8 +41,14 @@ const AdminPanel = ({ treeId, authToken, isAdmin = false, + // Propriétaire : supprime l'arbre et gère les co-gestionnaires. Un co-gestionnaire peut seulement quitter. + isOwner = false, + currentUserId = null, + oidcName = '', onClose, onTreeDeleted, + onLeftTree, + onOpenShare, onGedcomImported, onOpenPhotoStart, }) => { @@ -58,6 +66,7 @@ const AdminPanel = ({ const [savingPolicy, setSavingPolicy] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) + const [confirmLeave, setConfirmLeave] = useState(false) const [deleteConfirmName, setDeleteConfirmName] = useState('') const [gedcomFile, setGedcomFile] = useState(null) const [importingGedcom, setImportingGedcom] = useState(false) @@ -299,6 +308,22 @@ const AdminPanel = ({ } } + // Co-gestionnaire qui quitte : l'arbre disparaît de ses arbres, comme supprimé pour lui + const handleLeave = async () => { + if (!treeId || !authToken || saving || !currentUserId) return + setSaving(true) + setErrorMessage('') + try { + await removeManager(treeId, currentUserId, authToken) + onLeftTree?.(treeId) + onClose?.() + } catch { + setErrorMessage(t('Impossible de quitter la gestion de cet arbre.')) + } finally { + setSaving(false) + } + } + // ── Connexion Immich de l'arbre ────────────────────────────────────────────── const testImmichConnection = async () => { @@ -400,6 +425,7 @@ const AdminPanel = ({
    + @@ -414,6 +440,7 @@ const AdminPanel = ({ {successMessage &&
    {successMessage}
    } {section === 'visits' && } + {section === 'managers' && } {section === 'tree' && ( @@ -649,7 +676,28 @@ const AdminPanel = ({
    )} - {section === 'danger' && ( + {section === 'danger' && !isOwner && ( +
    +
    +
    + {t('Quitter la gestion de cet arbre')} +

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

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

    {t('Chargement…')}

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

    {t('Invitation')}

    +

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

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

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

    +

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

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

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

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

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

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

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

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

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

    } +
    + {errorMessage &&
    {errorMessage}
    } + + + + + ) + } + } + + return ( +
    +
    + Parenthèse +
    {body}
    +
    +
    + ) +} + +export default InvitationScreen diff --git a/frontend/src/components/SettingsPanels.css b/frontend/src/components/SettingsPanels.css index a338979..32beb61 100644 --- a/frontend/src/components/SettingsPanels.css +++ b/frontend/src/components/SettingsPanels.css @@ -56,6 +56,18 @@ .ts-row-actions { justify-content: flex-start; } } +/* ── Gestion : propriétaire et co-gestionnaires (issue #145) ── */ +.tm-row { padding: 12px 14px; } +.tm-who { display: flex; align-items: center; gap: 10px; min-width: 0; } +.tm-who .ts-row-text { min-width: 0; } +.tm-avatar { width: 34px; height: 34px; flex: none; border-radius: 50%; display: grid; place-items: center; background: var(--pz-a100); color: var(--pz-a700); font: 500 13px var(--pz-sans); } +.tm-avatar--pending { background: var(--pz-bg); color: var(--pz-muted); border: 1px dashed var(--pz-a300); } +.tm-mail { font: 400 13px var(--pz-sans); color: var(--pz-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tm-invite { display: flex; gap: 8px; } +.tm-invite .pz-input { flex: 1; min-width: 0; } +.contrib-invite-link input.contrib-link-url { border: 0; outline: none; min-height: 32px; } +@media (max-width: 480px) { .tm-invite { flex-direction: column; } } + /* ── Contributions : invitation ── */ .contrib-list-section { display: grid; gap: 18px; } .contrib-invite { display: grid; gap: 12px; padding: 16px; border-radius: var(--pz-r-m); background: var(--pz-a50); border: 1px solid var(--pz-a100); } @@ -259,3 +271,4 @@ .contrib-session-quick { justify-content: flex-end; } .contrib-field-label { width: 88px; } } +.tm-share-btn { justify-self: start; } diff --git a/frontend/src/components/TreeManagersSection.jsx b/frontend/src/components/TreeManagersSection.jsx new file mode 100644 index 0000000..bcc657f --- /dev/null +++ b/frontend/src/components/TreeManagersSection.jsx @@ -0,0 +1,221 @@ +import { useEffect, useState } from 'react' +import { inviteManager, invitationUrl, listManagers, removeManager, renewInvitationLink, revokeInvitation } from '../api/managersApi.js' +import PzBusy from './PzBusy.jsx' +import { dateLocale, t } from '../i18n/index.js' + +// Onglet Gestion des paramètres de l'arbre (issue #145). Le propriétaire invite des co-gestionnaires par leur +// adresse : Parenthèse n'envoie pas de mail, il donne un lien à transmettre. Les co-gestionnaires voient la liste. + +const ERRORS = { + already_manager: t('Cette adresse gère déjà cet arbre.'), + invalid_email: t('Cette adresse email ne semble pas valide.'), + too_many_invitations: t('Trop d’invitations en attente. Annulez-en une avant d’en envoyer une autre.'), +} + +function initialOf(person) { + return (person.firstName || person.email || '?').trim().charAt(0).toUpperCase() +} + +const TreeManagersSection = ({ treeId, authToken, currentUserId = null, oidcName = '', onOpenShare }) => { + const [state, setState] = useState({ loading: true, error: '', data: null }) + const [email, setEmail] = useState('') + const [busy, setBusy] = useState('') + const [message, setMessage] = useState({ error: '', success: '' }) + // Lien tout juste créé, montré en clair si le presse-papiers est refusé + const [link, setLink] = useState(null) + const [confirmRemove, setConfirmRemove] = useState(null) + // Co-gestionnaire tout juste retiré : il a pu lire le mot de passe de partage, on propose de le changer + const [removedName, setRemovedName] = useState('') + + const load = () => listManagers(treeId, authToken) + .then((data) => setState({ loading: false, error: '', data })) + .catch(() => setState({ loading: false, error: t('Impossible de charger les gestionnaires.'), data: null })) + + useEffect(() => { + setState({ loading: true, error: '', data: null }) + load() + }, [treeId, authToken]) // eslint-disable-line react-hooks/exhaustive-deps + + const offerLink = async (token, forEmail) => { + const url = invitationUrl(token) + let copied + try { await navigator.clipboard.writeText(url); copied = true } catch { copied = false } + setLink({ url, email: forEmail, copied }) + } + + const handleInvite = async (event) => { + event.preventDefault() + if (!email.trim() || busy) return + setBusy('invite') + setMessage({ error: '', success: '' }) + try { + const { token, invitation } = await inviteManager(treeId, email.trim(), authToken) + setEmail('') + await offerLink(token, invitation.email) + await load() + } catch (error) { + setMessage({ error: ERRORS[error?.payload?.error] || t('Impossible d’envoyer l’invitation.'), success: '' }) + } finally { + setBusy('') + } + } + + const handleCopy = async (invitation) => { + setBusy(`link-${invitation.id}`) + setMessage({ error: '', success: '' }) + try { + const { token } = await renewInvitationLink(treeId, invitation.id, authToken) + await offerLink(token, invitation.email) + } catch { + setMessage({ error: t('Impossible de créer le lien.'), success: '' }) + } finally { + setBusy('') + } + } + + const handleRevoke = async (invitation) => { + setBusy(`revoke-${invitation.id}`) + try { + await revokeInvitation(treeId, invitation.id, authToken) + if (link?.email === invitation.email) setLink(null) + await load() + } catch { + setMessage({ error: t('Impossible d’annuler l’invitation.'), success: '' }) + } finally { + setBusy('') + } + } + + const handleRemove = async (manager) => { + setBusy(`remove-${manager.userId}`) + try { + await removeManager(treeId, manager.userId, authToken) + setConfirmRemove(null) + setMessage({ error: '', success: '' }) + setRemovedName(manager.firstName || manager.email) + await load() + } catch { + setMessage({ error: t('Impossible de retirer ce co-gestionnaire.'), success: '' }) + } finally { + setBusy('') + } + } + + if (state.loading) return

    {t('Chargement…')}

    + if (state.error) return
    {state.error}
    + + const { canManage, managers, invitations } = state.data + return ( +
    +

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

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

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

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

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

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

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

    +
    +
    + )} +
    + ) +} + +export default TreeManagersSection diff --git a/frontend/src/components/coGestion.test.jsx b/frontend/src/components/coGestion.test.jsx new file mode 100644 index 0000000..986be6e --- /dev/null +++ b/frontend/src/components/coGestion.test.jsx @@ -0,0 +1,34 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import PersonCard from './PersonCard.jsx' +import AdminPanel from './AdminPanel.jsx' +import TreeDropdown from './navbar/TreeDropdown.jsx' + +// Co-gestion (issue #145) et « Modifier » de la fiche : ce que chaque rôle voit. Les droits eux-mêmes sont tenus +// par le serveur (backend/test/co-gestion.db.test.ts) ; ici, l'interface ne propose pas ce qui serait refusé. +const noop = () => {} +const person = { id: 'p1', firstName: 'Marie', lastName: 'Martin', isAlive: true } + +describe('fiche, « Modifier » d’un clic', () => { + it('proposé quand App le donne (administrateur, hors démo), hors mode modification', () => { + expect(renderToStaticMarkup()).toContain('pc-quick-edit') + }) + + it('absent pour la famille et la démo (App ne le donne pas), et déjà en modification', () => { + expect(renderToStaticMarkup()).not.toContain('pc-quick-edit') + expect(renderToStaticMarkup()).not.toContain('pc-quick-edit') + }) +}) + +describe('co-gestionnaire dans les menus', () => { + it('le menu des arbres nomme le rôle « Co-gestionnaire », plus « Admin »', () => { + const html = renderToStaticMarkup() + expect(html).toContain('Co-gestionnaire') + expect(html).not.toContain('>Admin<') + }) + + it('le panneau a un onglet Gestion', () => { + const html = renderToStaticMarkup() + expect(html).toContain('Gestion') + }) +}) diff --git a/frontend/src/components/navbar/TreeDropdown.jsx b/frontend/src/components/navbar/TreeDropdown.jsx index d31721c..86f2cdb 100644 --- a/frontend/src/components/navbar/TreeDropdown.jsx +++ b/frontend/src/components/navbar/TreeDropdown.jsx @@ -41,7 +41,7 @@ function TreeDropdown({ trees = [], activeTreeId, onSelect, onCreate }) {
    {tree.name}
    - {tree.role === 'owner' ? t('Propriétaire') : t('Admin')} + {tree.role === 'owner' ? t('Propriétaire') : t('Co-gestionnaire')}
    diff --git a/frontend/src/hooks/useAuth.js b/frontend/src/hooks/useAuth.js index a8b0d02..4865a5e 100644 --- a/frontend/src/hooks/useAuth.js +++ b/frontend/src/hooks/useAuth.js @@ -107,12 +107,12 @@ export function useAuth() { } } - const handleRegister = async (firstName, email, password) => { + const handleRegister = async (firstName, email, password, invitationToken) => { setAccountLoading(true) setAccountError('') try { - const result = await registerUser(firstName, email, password) + const result = await registerUser(firstName, email, password, invitationToken) const mergedUser = { ...(result.user || {}), firstName: firstName || result.user?.firstName || '', diff --git a/frontend/src/i18n/en/gestion.js b/frontend/src/i18n/en/gestion.js new file mode 100644 index 0000000..62773eb --- /dev/null +++ b/frontend/src/i18n/en/gestion.js @@ -0,0 +1,73 @@ +// Traductions anglaises, clé = texte français exact passé à t(). +// Co-gestion d'un arbre (issue #145) : onglet Gestion, page du lien d'invitation, suppression du compte +export default { + // Onglet Gestion (TreeManagersSection) + 'Gestion': 'Managers', + 'Co-gestionnaire': 'Co-manager', + 'Cette adresse gère déjà cet arbre.': 'This address already manages this tree.', + 'Cette adresse email ne semble pas valide.': 'This email address does not look valid.', + 'Trop d’invitations en attente. Annulez-en une avant d’en envoyer une autre.': 'Too many pending invitations. Cancel one before sending another.', + 'Impossible de charger les gestionnaires.': 'Could not load the managers.', + 'Impossible d’envoyer l’invitation.': 'Could not send the invitation.', + 'Impossible de créer le lien.': 'Could not create the link.', + 'Impossible d’annuler l’invitation.': 'Could not cancel the invitation.', + 'Impossible de retirer ce co-gestionnaire.': 'Could not remove this co-manager.', + 'Les co-gestionnaires modifient l’arbre directement et valident les propositions de la famille, comme vous. Seul le propriétaire invite, retire un co-gestionnaire ou supprime l’arbre.': + 'Co-managers edit the tree directly and review the family’s suggestions, like you. Only the owner invites or removes a co-manager, or deletes the tree.', + 'Retrait en cours': 'Removing', + 'Invité le {date}, en attente': 'Invited on {date}, pending', + 'Création du lien': 'Creating the link', + 'Copier le lien': 'Copy the link', + 'Annulation en cours': 'Cancelling', + 'Lien pour {email}': 'Link for {email}', + 'Lien d’invitation': 'Invitation link', + 'Envoyez-le par message ou par mail. Il sert une fois et vaut 14 jours. « Copier le lien » en crée un nouveau, l’ancien ne marche plus.': + 'Send it by text or email. It works once and lasts 14 days. “Copy the link” creates a new one, and the old one stops working.', + '{name} ne gère plus cet arbre.': '{name} no longer manages this tree.', + 'Cette personne a pu voir le mot de passe de partage : avec lui, elle peut encore ouvrir l’arbre en lecture. Pour lui couper aussi la lecture, changez-le, puis donnez le nouveau à la famille.': + 'This person may have seen the sharing password, which still lets them open the tree to read it. To cut that too, change it, then give the new one to the family.', + 'Changer le mot de passe de partage': 'Change the sharing password', + 'Inviter un co-gestionnaire': 'Invite a co-manager', + 'Son adresse email': 'Their email address', + 'prenom@exemple.fr': 'name@example.com', + 'Invitation en cours': 'Inviting', + 'Inviter': 'Invite', + 'Parenthèse n’envoie pas de mail : vous recevez un lien à lui transmettre. S’il se connecte avec {name} et cette adresse, il est ajouté sans le lien.': + 'Parenthèse sends no email: you get a link to pass on. If they sign in with {name} and this address, they are added without the link.', + 'Parenthèse n’envoie pas de mail : vous recevez un lien à lui transmettre.': 'Parenthèse sends no email: you get a link to pass on.', + + // Avancé, pour un co-gestionnaire (AdminPanel) + 'Impossible de quitter la gestion de cet arbre.': 'Could not stop managing this tree.', + 'Quitter la gestion de cet arbre': 'Stop managing this tree', + 'Vous ne pourrez plus le modifier. Seul son propriétaire peut le supprimer.': 'You will no longer be able to edit it. Only its owner can delete it.', + 'Rester': 'Stay', + 'Quitter': 'Leave', + + // Page du lien d'invitation (InvitationScreen) + 'Ce lien a déjà servi. Si c’était le vôtre, l’arbre est dans vos arbres.': 'This link has already been used. If it was yours, the tree is in your trees.', + 'Ce lien a expiré. Demandez-en un nouveau à la personne qui vous a invité.': 'This link has expired. Ask the person who invited you for a new one.', + 'Ce lien d’invitation ne marche pas. Demandez-en un nouveau à la personne qui vous a invité.': 'This invitation link does not work. Ask the person who invited you for a new one.', + 'Invitation': 'Invitation', + 'Ouvrir Parenthèse': 'Open Parenthèse', + 'Gérer « {name} »': 'Manage “{name}”', + '{name} vous invite à modifier cet arbre avec votre propre compte. L’invitation est pour {email}.': '{name} invites you to edit this tree with your own account. The invitation is for {email}.', + 'Vous êtes invité à modifier cet arbre avec votre propre compte. L’invitation est pour {email}.': 'You are invited to edit this tree with your own account. The invitation is for {email}.', + 'Vous êtes connecté avec {email}.': 'You are signed in as {email}.', + 'Ouverture de l’arbre': 'Opening the tree', + 'Accepter et ouvrir l’arbre': 'Accept and open the tree', + 'Changer de compte': 'Use another account', + 'J’ai déjà un compte Parenthèse': 'I already have a Parenthèse account', + 'Le lien sert une fois et vaut 14 jours.': 'The link works once and lasts 14 days.', + 'L’adresse à laquelle vous avez été invité.': 'The address you were invited at.', + 'Créer mon compte et ouvrir l’arbre': 'Create my account and open the tree', + + // Suppression du compte (AccountDashboard) + '« {name} » sera supprimé pour tout le monde': '“{name}” will be deleted for everyone', + '{names} le gèrent avec vous. Ils n’y auront plus accès, la famille non plus.': '{names} manage it with you. They will lose access, and so will the family.', + '{name} le gère avec vous. Cette personne n’y aura plus accès, la famille non plus.': '{name} manages it with you. They will lose access, and so will the family.', + + // Erreurs (errorMessages.js) + "Cette invitation est pour une autre adresse. Utilisez l'adresse à laquelle vous avez été invité.": 'This invitation is for another address. Use the address you were invited at.', + "Ce lien d'invitation ne marche plus. Demandez-en un nouveau à la personne qui vous a invité.": 'This invitation link no longer works. Ask the person who invited you for a new one.', + 'Vous êtes déjà propriétaire de cet arbre.': 'You already own this tree.', +} diff --git a/frontend/src/i18n/en/index.js b/frontend/src/i18n/en/index.js index d0be806..89631f5 100644 --- a/frontend/src/i18n/en/index.js +++ b/frontend/src/i18n/en/index.js @@ -6,6 +6,7 @@ import contribution from './contribution.js' import compte from './compte.js' import photos from './photos.js' import souvenir from './souvenir.js' +import gestion from './gestion.js' export default { ...app, @@ -15,5 +16,6 @@ export default { ...compte, ...photos, ...souvenir, + ...gestion, } diff --git a/frontend/src/utils/errorMessages.js b/frontend/src/utils/errorMessages.js index a2d9963..65cd788 100644 --- a/frontend/src/utils/errorMessages.js +++ b/frontend/src/utils/errorMessages.js @@ -96,6 +96,19 @@ export function getAccountErrorMessage(error) { return t("Cette instance n'accepte plus de nouveaux comptes. Demandez à la personne qui l'héberge.") } + // Lien d'invitation à co-gérer un arbre (issue #145) + if (errorCode === 'invitation_email_mismatch') { + return t("Cette invitation est pour une autre adresse. Utilisez l'adresse à laquelle vous avez été invité.") + } + + if (errorCode === 'invitation_invalid' || errorCode === 'invitation_used' || errorCode === 'invitation_expired') { + return t("Ce lien d'invitation ne marche plus. Demandez-en un nouveau à la personne qui vous a invité.") + } + + if (errorCode === 'already_owner') { + return t('Vous êtes déjà propriétaire de cet arbre.') + } + if (errorCode === 'citation_text_required') { return t('Ajoutez le texte de la citation.') } From 9221f8b63fddb9c1f9f91d2ffa0da7ea54a2def1 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sun, 25 Oct 2026 17:05:11 +0100 Subject: [PATCH 4/6] CHANGELOG : 1.5.0, co-gestion et correctifs de la fiche Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79b235b..b152932 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ Notable changes to Parenthèse. Dates are in YYYY-MM-DD format. +## 1.5.0 (unreleased) + +### Several managers for one tree + +- The owner can invite co-managers from the tree settings, in a new Managers tab. Parenthèse sends no email: the owner gets a link to pass on. The link works once, lasts 14 days, and can be cancelled. Copying it again creates a new link and the old one stops working (#145, asked by @milan616). +- A co-manager edits the tree and reviews the family's suggestions like the owner. Only the owner invites or removes a co-manager and deletes the tree. A co-manager can leave from the Advanced tab. +- Immich stays with the owner. Its key opens the owner's whole library, so co-managers do not see it or change it. +- With a password account, the link is needed and the account must use the invited address. With OIDC, a co-manager whose provider marks the invited address as verified is added at sign-in, without the link. An unverified address is never matched. +- An invitation opens an account even when `ALLOW_REGISTRATION=false`, at the invited address only. +- Removing a co-manager takes effect on their next request, and also drops any access they had saved with the sharing password. The settings then offer to change that password, since they may have seen it. +- Deleting your account now names the co-managers of the trees you own, which are deleted for them too. +- In the tree menu, "Admin" is now "Co-manager". + +### Fixes + +- An admin can switch a person's profile to edit mode from a new Edit button on the profile. +- The "?" of "needs checking" shows whole on round portraits. It was cut by the frame. +- On iPad, the tree no longer stops short of the bottom of the screen. The canvas follows its real size instead of the window height read at load. + ## 1.4.0 (2026-10-23) ### Family tabs From aa50123e9fbbfdb301861a8ff1f9c9e42518b3cb Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sun, 25 Oct 2026 17:20:19 +0100 Subject: [PATCH 5/6] =?UTF-8?q?Param=C3=A8tres=20:=20cinq=20onglets=20qui?= =?UTF-8?q?=20tiennent=20=C3=A0=20360=20px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/AdminPanel.jsx | 2 +- frontend/src/components/SettingsPanels.css | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index 91047b0..c63384a 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -423,7 +423,7 @@ const AdminPanel = ({