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))