diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa31ec..00f740b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,30 @@ Notable changes to Parenthèse. Dates are in YYYY-MM-DD format. -## Unreleased +## 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". ### Search for someone elsewhere - "Someone elsewhere" in the "+" menu opens a panel on the right of the tree: type a name, birth year and place, and search public records. Links to Gallica (old newspapers), FranceArchives (military registers), Mémoire des hommes, Léonore, Geneanet, Open Archives, WikiTree, FreeBMD and FamilySearch open in the browser with the name typed in when the site allows it. - The server can query deaths in France since 1970 (INSEE records via matchID) and WikiTree, once listed in `SEARCH_SOURCES`. Each source says what it covers and what is sent. The owner adds a result to a person in the tree: empty fields only, marked as needing checking, source kept in the note. +### 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 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 b81f81a..3c933a3 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 { searchSourceRoutes } from './routes/search-sources.js' import { mediaRoutes } from './routes/media.js' import { oidcRoutes } from './routes/oidc.js' @@ -160,6 +161,7 @@ export function createApp() { app.register(gedcomRoutes) app.register(visitRoutes) app.register(immichRoutes) + app.register(managerRoutes) app.register(searchSourceRoutes) app.setErrorHandler((error: FastifyError | Error, request, reply) => { 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..4c4a986 --- /dev/null +++ b/backend/src/lib/tree-invitations.ts @@ -0,0 +1,121 @@ +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, 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) + 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..95ef14b 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,26 @@ 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 + } 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 new file mode 100644 index 0000000..1f88080 --- /dev/null +++ b/backend/src/routes/managers.ts @@ -0,0 +1,240 @@ +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, + hashInvitationToken, + 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), 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) }) +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' }) + } + + // 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 }) + 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) { + // 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, + 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..0f1afc2 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,11 @@ 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 (et + // devant être celle du compte, voir claimInvitationsForVerifiedEmail) + 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..a73b778 --- /dev/null +++ b/backend/test/co-gestion.db.test.ts @@ -0,0 +1,264 @@ +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 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 + 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) + 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 () => { + 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) + }) + + 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 }) { + 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("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}` + 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)) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 45b9568..731f34a 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' @@ -488,6 +489,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() { @@ -2973,6 +2982,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 (
@@ -3370,7 +3398,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/AccessGate.jsx b/frontend/src/components/AccessGate.jsx index ed565e6..03f0d6c 100644 --- a/frontend/src/components/AccessGate.jsx +++ b/frontend/src/components/AccessGate.jsx @@ -5,6 +5,7 @@ import PzBusy from './PzBusy.jsx' import LanguageSwitch from './navbar/LanguageSwitch' import { t } from '../i18n/index.js' import { readContributorName, saveContributorName } from '../utils/contributorName.js' +import { initialOf } from '../utils/initial.js' // Phrase traduite d'un bloc : les passages entre astérisques passent en , {slot} reçoit un nœud. function richText(text, slotNode = null) { @@ -78,7 +79,7 @@ const AccessGate = ({ {!treeNotFound && (ownerLabel || treeDescription) && (
{ownerLabel && ( - + )}
{ownerLabel &&

{richText(t('Partagé par {slot}'), {ownerLabel})}

} diff --git a/frontend/src/components/AccountDashboard.jsx b/frontend/src/components/AccountDashboard.jsx index 5f8eb32..7f693ec 100644 --- a/frontend/src/components/AccountDashboard.jsx +++ b/frontend/src/components/AccountDashboard.jsx @@ -6,6 +6,7 @@ import PzBusy from './PzBusy.jsx' import LanguageSwitch from './navbar/LanguageSwitch' import { t } from '../i18n/index.js' import { PRIVACY_URL } from '../utils/siteLinks.js' +import { initialOf } from '../utils/initial.js' // Phrase traduite d'un bloc : les passages entre astérisques passent en , {slot} reçoit un nœud. function richText(text, slotNode = null) { @@ -278,7 +279,7 @@ const AccountDashboard = ({ className={`tree-item pz-tree-item ${isActive ? 'active' : ''}`} onClick={() => onOpenTree(tree.id)} > - + {tree.name} {isActive && {t('Ouvert')}}