mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Merge pull request #148 from IliesAllali/feat/cogestion-145
Plusieurs gestionnaires pour un arbre (#145)
This commit is contained in:
37 files changed
+1533
-39
No files matched your search
+18
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -12,11 +12,14 @@ export async function isRegistrationOpen(prisma: PrismaClient): Promise<boolean>
|
||||
// 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<T>(
|
||||
prisma: PrismaClient,
|
||||
create: (tx: Prisma.TransactionClient) => Promise<T>,
|
||||
{ invited = false }: { invited?: boolean } = {},
|
||||
): Promise<T | null> {
|
||||
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.
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<AcceptResult> {
|
||||
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<string[]> {
|
||||
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}`
|
||||
}
|
||||
@@ -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 })
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
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' })
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string>) {
|
||||
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' })
|
||||
})
|
||||
}
|
||||
@@ -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() },
|
||||
|
||||
@@ -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),
|
||||
} : {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof import('../src/lib/oidc.js')>()),
|
||||
...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<string, unknown>,
|
||||
headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
remoteAddress: nextIp(),
|
||||
})
|
||||
return { status: res.statusCode, body: res.body ? res.json() : null }
|
||||
}
|
||||
|
||||
async function register(name: string, extra: Record<string, unknown> = {}) {
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
|
||||
@@ -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/<jeton>, 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 (
|
||||
<div className="app">
|
||||
<InvitationScreen
|
||||
invitationToken={INVITATION_TOKEN}
|
||||
userToken={auth.userAuth.token || ''}
|
||||
userEmail={auth.userAuth.user?.email || ''}
|
||||
loading={auth.accountLoading}
|
||||
errorMessage={auth.accountError}
|
||||
onLogin={auth.handleLogin}
|
||||
onRegister={auth.handleRegister}
|
||||
onOidcLogin={authOptions.oidc ? () => window.location.assign(oidcStartUrl(`/invitation/${INVITATION_TOKEN}`)) : undefined}
|
||||
onLogout={handleLogout}
|
||||
onOpenApp={() => window.location.assign('/')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (tree.bootState === 'loading') {
|
||||
return (
|
||||
<div className="app app-loading" />
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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 <em>, {slot} reçoit un nœud.
|
||||
function richText(text, slotNode = null) {
|
||||
@@ -78,7 +79,7 @@ const AccessGate = ({
|
||||
{!treeNotFound && (ownerLabel || treeDescription) && (
|
||||
<div className="pz-gate-owner">
|
||||
{ownerLabel && (
|
||||
<span className="pz-gate-mono" aria-hidden="true">{ownerLabel.trim().charAt(0).toUpperCase()}</span>
|
||||
<span className="pz-gate-mono" aria-hidden="true">{initialOf(ownerLabel)}</span>
|
||||
)}
|
||||
<div className="pz-gate-owner-text">
|
||||
{ownerLabel && <p className="access-gate-tree-meta pz-small">{richText(t('Partagé par {slot}'), <strong>{ownerLabel}</strong>)}</p>}
|
||||
|
||||
@@ -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 <em>, {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)}
|
||||
>
|
||||
<span className="pz-tree-mono" aria-hidden="true">{(tree.name || '?').trim().charAt(0).toUpperCase()}</span>
|
||||
<span className="pz-tree-mono" aria-hidden="true">{initialOf(tree.name)}</span>
|
||||
<span className="tree-name pz-tree-name">{tree.name}</span>
|
||||
{isActive && <span className="pz-tag pz-tag--accent">{t('Ouvert')}</span>}
|
||||
<ChevronRight size={18} aria-hidden="true" className="pz-tree-chevron" />
|
||||
@@ -344,6 +345,19 @@ const AccountDashboard = ({
|
||||
</li>
|
||||
<li>{t("Les arbres partagés avec vous restent à leurs propriétaires. Vous n'y aurez simplement plus accès.")}</li>
|
||||
</ul>
|
||||
{/* Arbres gérés avec d'autres : ils partent aussi pour leurs co-gestionnaires (issue #145) */}
|
||||
{(trees || []).filter((tree) => tree.role === 'owner' && tree.coManagers?.length > 0).map((tree) => (
|
||||
<div key={tree.id} className="ts-danger account-comanaged">
|
||||
<div className="ts-row-text">
|
||||
<strong>{t('« {name} » sera supprimé pour tout le monde', { name: tree.name })}</strong>
|
||||
<span className="pz-small">
|
||||
{tree.coManagers.length > 1
|
||||
? t('{names} le gèrent avec vous. Ils n’y auront plus accès, la famille non plus.', { names: tree.coManagers.join(', ') })
|
||||
: t('{name} le gère avec vous. Cette personne n’y aura plus accès, la famille non plus.', { name: tree.coManagers[0] })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<form className="pz-auth-form" onSubmit={handleDeleteSubmit}>
|
||||
<div className="pz-field">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import './AccountScreens.css'
|
||||
import { t } from '../i18n/index.js'
|
||||
import { initialOf } from '../utils/initial.js'
|
||||
|
||||
const AccountPanel = ({
|
||||
visible,
|
||||
@@ -50,7 +51,7 @@ const AccountPanel = ({
|
||||
|
||||
<div className="ap-linked">
|
||||
<span className="ap-avatar" aria-hidden="true">
|
||||
{linkedPerson?.photo ? <img src={linkedPerson.photo} alt="" /> : (linkedPerson?.firstName || userDisplayName || 'C').charAt(0).toUpperCase()}
|
||||
{linkedPerson?.photo ? <img src={linkedPerson.photo} alt="" /> : initialOf(linkedPerson?.firstName || userDisplayName, 'C')}
|
||||
</span>
|
||||
<p className="pz-small">
|
||||
{linkedPerson
|
||||
@@ -86,7 +87,7 @@ const AccountPanel = ({
|
||||
onClick={() => onSelectPerson?.(person.id)}
|
||||
>
|
||||
<span className="ap-item-avatar" aria-hidden="true">
|
||||
{person.photo ? <img src={person.photo} alt="" /> : (person.firstName || '?').charAt(0)}
|
||||
{person.photo ? <img src={person.photo} alt="" /> : initialOf(person.firstName)}
|
||||
</span>
|
||||
<span className="ap-item-name">{person.firstName} <em>{person.lastName || ''}</em></span>
|
||||
{isSelected && <span className="pz-tag pz-tag--accent">{t("C'est moi")}</span>}
|
||||
|
||||
@@ -65,3 +65,4 @@
|
||||
.ap-item-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.ap-item-name { flex: 1; min-width: 0; font: 400 16px/1.2 var(--pz-serif); color: var(--pz-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ap-item-name em { font-style: italic; font-weight: 300; }
|
||||
.invite-doors { display: grid; gap: 10px; }
|
||||
@@ -6,6 +6,7 @@ import PzBusy from './PzBusy.jsx'
|
||||
import { t } from '../i18n/index.js'
|
||||
import { firstNameLabel } from '../utils/personName.js'
|
||||
import { SEX_CHOICES } from '../utils/personSex.js'
|
||||
import { initialOf } from '../utils/initial.js'
|
||||
|
||||
// Titre traduit d'un bloc : le passage entre <em> et </em> garde son italique
|
||||
function withEmphasis(text) {
|
||||
@@ -354,7 +355,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
|
||||
<li key={p.id}>
|
||||
<button type="button" className="apn-dropdown-item" onClick={() => handleAddRelation(p)}>
|
||||
<span className="apn-avatar" aria-hidden="true">
|
||||
{p.photo ? <img src={p.photo} alt="" /> : (p.firstName || '').trim().charAt(0) || '?'}
|
||||
{p.photo ? <img src={p.photo} alt="" /> : initialOf(p.firstName)}
|
||||
</span>
|
||||
<span className="apn-dropdown-name">{firstNameLabel(p)} <em>{p.lastName || ''}</em></span>
|
||||
{(p.birthYear || p.birthDate) && (
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
} from '../api/immichApi.js'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import TreeVisitsSection from './TreeVisitsSection.jsx'
|
||||
import TreeManagersSection from './TreeManagersSection.jsx'
|
||||
import { removeManager } from '../api/managersApi.js'
|
||||
import { t } from '../i18n/index.js'
|
||||
import { readGedcomFile } from '../utils/gedcomText.js'
|
||||
import { readGedcomBundle, zipErrorMessage } from '../utils/gedcomBundle.js'
|
||||
@@ -39,8 +41,14 @@ const AdminPanel = ({
|
||||
treeId,
|
||||
authToken,
|
||||
isAdmin = false,
|
||||
// Propriétaire : supprime l'arbre et gère les co-gestionnaires. Un co-gestionnaire peut seulement quitter.
|
||||
isOwner = false,
|
||||
currentUserId = null,
|
||||
oidcName = '',
|
||||
onClose,
|
||||
onTreeDeleted,
|
||||
onLeftTree,
|
||||
onOpenShare,
|
||||
onGedcomImported,
|
||||
onOpenPhotoStart,
|
||||
}) => {
|
||||
@@ -58,6 +66,7 @@ const AdminPanel = ({
|
||||
const [savingPolicy, setSavingPolicy] = useState(false)
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [confirmLeave, setConfirmLeave] = useState(false)
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState('')
|
||||
const [gedcomFile, setGedcomFile] = useState(null)
|
||||
const [importingGedcom, setImportingGedcom] = useState(false)
|
||||
@@ -299,6 +308,22 @@ const AdminPanel = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Co-gestionnaire qui quitte : l'arbre disparaît de ses arbres, comme supprimé pour lui
|
||||
const handleLeave = async () => {
|
||||
if (!treeId || !authToken || saving || !currentUserId) return
|
||||
setSaving(true)
|
||||
setErrorMessage('')
|
||||
try {
|
||||
await removeManager(treeId, currentUserId, authToken)
|
||||
onLeftTree?.(treeId)
|
||||
onClose?.()
|
||||
} catch {
|
||||
setErrorMessage(t('Impossible de quitter la gestion de cet arbre.'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connexion Immich de l'arbre ──────────────────────────────────────────────
|
||||
|
||||
const testImmichConnection = async () => {
|
||||
@@ -398,8 +423,9 @@ const AdminPanel = ({
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div className="pz-tabs" role="tablist">
|
||||
<div className="pz-tabs ts-tabs" role="tablist">
|
||||
<button type="button" role="tab" aria-selected={section === 'tree'} className="pz-tab" onClick={() => setSection('tree')}>{t('Votre arbre')}</button>
|
||||
<button type="button" role="tab" aria-selected={section === 'managers'} className="pz-tab" onClick={() => setSection('managers')}>{t('Gestion')}</button>
|
||||
<button type="button" role="tab" aria-selected={section === 'visits'} className="pz-tab" onClick={() => setSection('visits')}>{t('Visites')}</button>
|
||||
<button type="button" role="tab" aria-selected={section === 'data'} className="pz-tab" onClick={() => setSection('data')}>{t('Données')}</button>
|
||||
<button type="button" role="tab" aria-selected={section === 'danger'} className="pz-tab" onClick={() => setSection('danger')}>{t('Avancé')}</button>
|
||||
@@ -414,6 +440,7 @@ const AdminPanel = ({
|
||||
{successMessage && <div className="pz-success" role="status">{successMessage}</div>}
|
||||
|
||||
{section === 'visits' && <TreeVisitsSection treeId={treeId} authToken={authToken} />}
|
||||
{section === 'managers' && <TreeManagersSection treeId={treeId} authToken={authToken} currentUserId={currentUserId} oidcName={oidcName} onOpenShare={onOpenShare} />}
|
||||
|
||||
{section === 'tree' && (
|
||||
<form className="ts-panel" onSubmit={handleSaveTree}>
|
||||
@@ -649,7 +676,28 @@ const AdminPanel = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'danger' && (
|
||||
{section === 'danger' && !isOwner && (
|
||||
<div className="ts-panel">
|
||||
<div className="ts-danger">
|
||||
<div className="ts-row-text">
|
||||
<strong>{t('Quitter la gestion de cet arbre')}</strong>
|
||||
<p className="pz-small">{t('Vous ne pourrez plus le modifier. Seul son propriétaire peut le supprimer.')}</p>
|
||||
</div>
|
||||
{confirmLeave ? (
|
||||
<div className="ts-row-actions">
|
||||
<button type="button" className="contrib-confirm-no" onClick={() => setConfirmLeave(false)}>{t('Rester')}</button>
|
||||
<button type="button" className="pz-btn pz-btn--danger pz-btn--sm" onClick={handleLeave} disabled={saving}>
|
||||
<PzBusy busy={saving} busyLabel={t('En cours')}>{t('Quitter')}</PzBusy>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className="pz-btn pz-btn--danger-ghost pz-btn--sm" onClick={() => setConfirmLeave(true)}>{t('Quitter')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'danger' && isOwner && (
|
||||
<div className="ts-panel">
|
||||
<div className="ts-danger">
|
||||
<div className="ts-row-text">
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LogIn } from 'lucide-react'
|
||||
import logoUrl from '../assets/parenthese-logo.svg?url'
|
||||
import './AccountScreens.css'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import { acceptInvitation, fetchInvitation } from '../api/managersApi.js'
|
||||
import { getAccountErrorMessage } from '../utils/errorMessages.js'
|
||||
import { t } from '../i18n/index.js'
|
||||
|
||||
// Page d'un lien d'invitation à co-gérer un arbre (/invitation/<jeton>, issue #145). Trois portes selon l'instance :
|
||||
// le fournisseur OIDC, un compte existant, un compte à créer (même inscription fermée : l'invitation l'ouvre).
|
||||
// Une fois accepté, l'arbre s'ouvre (le serveur l'a mis en dernier arbre ouvert).
|
||||
|
||||
const GONE = {
|
||||
invitation_used: t('Ce lien a déjà servi. Si c’était le vôtre, l’arbre est dans vos arbres.'),
|
||||
invitation_expired: t('Ce lien a expiré. Demandez-en un nouveau à la personne qui vous a invité.'),
|
||||
invitation_invalid: t('Ce lien d’invitation ne marche pas. Demandez-en un nouveau à la personne qui vous a invité.'),
|
||||
}
|
||||
|
||||
const InvitationScreen = ({
|
||||
invitationToken,
|
||||
userToken = '',
|
||||
userEmail = '',
|
||||
loading = false,
|
||||
errorMessage = '',
|
||||
onLogin,
|
||||
onRegister,
|
||||
onOidcLogin,
|
||||
onLogout,
|
||||
onOpenApp,
|
||||
}) => {
|
||||
const [preview, setPreview] = useState({ loading: true, data: null, error: '' })
|
||||
const [mode, setMode] = useState('choose')
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [accepting, setAccepting] = useState(false)
|
||||
const [acceptError, setAcceptError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
fetchInvitation(invitationToken)
|
||||
.then((data) => { if (active) setPreview({ loading: false, data, error: '' }) })
|
||||
.catch((error) => { if (active) setPreview({ loading: false, data: null, error: error?.payload?.error || 'invitation_invalid' }) })
|
||||
return () => { active = false }
|
||||
}, [invitationToken])
|
||||
|
||||
const handleAccept = async () => {
|
||||
setAccepting(true)
|
||||
setAcceptError('')
|
||||
try {
|
||||
await acceptInvitation(invitationToken, userToken)
|
||||
onOpenApp()
|
||||
} catch (error) {
|
||||
setAcceptError(getAccountErrorMessage(error))
|
||||
setAccepting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
const safeEmail = email.trim().toLowerCase()
|
||||
// Créé par l'invitation, le compte est déjà co-gestionnaire ; connecté, il reste à accepter
|
||||
if (mode === 'register') {
|
||||
if (await onRegister(firstName.trim(), safeEmail, password, invitationToken)) onOpenApp()
|
||||
} else {
|
||||
await onLogin(safeEmail, password)
|
||||
}
|
||||
}
|
||||
|
||||
const invitation = preview.data
|
||||
let body
|
||||
if (preview.loading) {
|
||||
body = <p className="pz-small ts-loading">{t('Chargement…')}</p>
|
||||
} else if (!invitation) {
|
||||
body = (
|
||||
<>
|
||||
<div className="pz-auth-head">
|
||||
<h1 className="pz-title">{t('Invitation')}</h1>
|
||||
<p className="pz-sub">{GONE[preview.error] || GONE.invitation_invalid}</p>
|
||||
</div>
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--block" onClick={onOpenApp}>{t('Ouvrir Parenthèse')}</button>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
const head = (
|
||||
<div className="pz-auth-head">
|
||||
<h1 className="pz-title">{t('Gérer « {name} »', { name: invitation.treeName })}</h1>
|
||||
<p className="pz-sub">
|
||||
{invitation.invitedBy
|
||||
? t('{name} vous invite à modifier cet arbre avec votre propre compte. L’invitation est pour {email}.', { name: invitation.invitedBy, email: invitation.emailHint })
|
||||
: t('Vous êtes invité à modifier cet arbre avec votre propre compte. L’invitation est pour {email}.', { email: invitation.emailHint })}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
if (userToken) {
|
||||
body = (
|
||||
<>
|
||||
{head}
|
||||
<p className="pz-small">{t('Vous êtes connecté avec {email}.', { email: userEmail })}</p>
|
||||
{acceptError && <div className="account-error pz-error" role="alert">{acceptError}</div>}
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--block" onClick={handleAccept} disabled={accepting}>
|
||||
<PzBusy busy={accepting} busyLabel={t('Ouverture de l’arbre')}>{t('Accepter et ouvrir l’arbre')}</PzBusy>
|
||||
</button>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--block" onClick={onLogout} disabled={accepting}>{t('Changer de compte')}</button>
|
||||
</>
|
||||
)
|
||||
} else if (mode === 'choose') {
|
||||
body = (
|
||||
<>
|
||||
{head}
|
||||
<div className="invite-doors">
|
||||
{invitation.oidc && onOidcLogin && (
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--block" onClick={onOidcLogin}>
|
||||
<LogIn size={16} aria-hidden="true" />
|
||||
{t('Se connecter avec {name}', { name: invitation.oidc.name })}
|
||||
</button>
|
||||
)}
|
||||
{invitation.oidc && invitation.passwordLogin && <div className="pz-divider">{t('ou')}</div>}
|
||||
{invitation.passwordLogin && (
|
||||
<>
|
||||
<button type="button" className={`pz-btn pz-btn--block ${invitation.oidc ? 'pz-btn--secondary' : 'pz-btn--primary'}`} onClick={() => setMode('login')}>{t('J’ai déjà un compte Parenthèse')}</button>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--block" onClick={() => setMode('register')}>{t('Créer mon compte')}</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="pz-hint">{t('Le lien sert une fois et vaut 14 jours.')}</p>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
const isRegister = mode === 'register'
|
||||
body = (
|
||||
<>
|
||||
{head}
|
||||
<form className="pz-auth-form" onSubmit={handleSubmit}>
|
||||
{isRegister && (
|
||||
<div className="pz-field">
|
||||
<label htmlFor="inviteFirstName">{t('Prénom')}</label>
|
||||
<input id="inviteFirstName" type="text" autoComplete="given-name" value={firstName} maxLength={100} onChange={(event) => setFirstName(event.target.value)} required />
|
||||
</div>
|
||||
)}
|
||||
<div className="pz-field">
|
||||
<label htmlFor="inviteEmail">{t('Email')}</label>
|
||||
<input id="inviteEmail" type="email" autoComplete="email" value={email} onChange={(event) => setEmail(event.target.value)} required />
|
||||
<p className="pz-hint">{t('L’adresse à laquelle vous avez été invité.')}</p>
|
||||
</div>
|
||||
<div className="pz-field">
|
||||
<label htmlFor="invitePassword">{t('Mot de passe')}</label>
|
||||
<input id="invitePassword" type="password" autoComplete={isRegister ? 'new-password' : 'current-password'} value={password} onChange={(event) => setPassword(event.target.value)} minLength={8} required />
|
||||
{isRegister && <p className="pz-hint">{t('8 caractères minimum.')}</p>}
|
||||
</div>
|
||||
{errorMessage && <div className="account-error pz-error" role="alert">{errorMessage}</div>}
|
||||
<button type="submit" className="pz-btn pz-btn--primary pz-btn--block" disabled={loading || !email.trim() || password.length < 8}>
|
||||
<PzBusy busy={loading} busyLabel={isRegister ? t('Création du compte') : t('Connexion')}>
|
||||
{isRegister ? t('Créer mon compte et ouvrir l’arbre') : t('Se connecter')}
|
||||
</PzBusy>
|
||||
</button>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--block" onClick={() => setMode('choose')} disabled={loading}>{t('Retour')}</button>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pz-screen invitation-screen">
|
||||
<div className="pz-screen-inner">
|
||||
<img src={logoUrl} alt="Parenthèse" className="pz-screen-logo" />
|
||||
<div className="pz-card pz-screen-card pz-auth">{body}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InvitationScreen
|
||||
@@ -355,8 +355,13 @@ const PersonCard = ({
|
||||
setTimeout(() => setSaveSuccess(false), 2000)
|
||||
return 'saved'
|
||||
} catch (error) {
|
||||
// Serveur injoignable (status 0, message traduit par le client) ; sinon un texte clair, jamais un message technique
|
||||
setSaveError(error?.status === 0 ? error.message : t("L'enregistrement a échoué. Vos modifications sont gardées, réessayez."))
|
||||
// Serveur injoignable (status 0, message traduit par le client) ; droits retirés entre-temps (un co-gestionnaire
|
||||
// retiré, issue #145) : réessayer ne servirait à rien ; sinon un texte clair, jamais un message technique
|
||||
setSaveError(error?.status === 0
|
||||
? error.message
|
||||
: error?.status === 403
|
||||
? t("Vous ne pouvez plus modifier cet arbre. Rechargez la page pour voir vos droits actuels.")
|
||||
: t("L'enregistrement a échoué. Vos modifications sont gardées, réessayez."))
|
||||
return 'blocked'
|
||||
} finally {
|
||||
setSaving(false)
|
||||
|
||||
@@ -56,6 +56,22 @@
|
||||
.ts-row-actions { justify-content: flex-start; }
|
||||
}
|
||||
|
||||
/* Cinq onglets : ils rétrécissent (texte sur deux lignes) au lieu de déborder du panneau sur un petit téléphone */
|
||||
.ts-tabs .pz-tab { min-width: 0; }
|
||||
@media (max-width: 420px) { .ts-tabs .pz-tab { padding: 0 4px; font-size: 12.5px; line-height: 1.2; } }
|
||||
|
||||
/* ── Gestion : propriétaire et co-gestionnaires (issue #145) ── */
|
||||
.tm-row { padding: 12px 14px; }
|
||||
.tm-who { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.tm-who .ts-row-text { min-width: 0; }
|
||||
.tm-avatar { width: 34px; height: 34px; flex: none; border-radius: 50%; display: grid; place-items: center; background: var(--pz-a100); color: var(--pz-a700); font: 500 13px var(--pz-sans); }
|
||||
.tm-avatar--pending { background: var(--pz-bg); color: var(--pz-muted); border: 1px dashed var(--pz-a300); }
|
||||
.tm-mail { font: 400 13px var(--pz-sans); color: var(--pz-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tm-invite { display: flex; gap: 8px; }
|
||||
.tm-invite .pz-input { flex: 1; min-width: 0; }
|
||||
.contrib-invite-link input.contrib-link-url { border: 0; outline: none; min-height: 32px; }
|
||||
@media (max-width: 480px) { .tm-invite { flex-direction: column; } }
|
||||
|
||||
/* ── Contributions : invitation ── */
|
||||
.contrib-list-section { display: grid; gap: 18px; }
|
||||
.contrib-invite { display: grid; gap: 12px; padding: 16px; border-radius: var(--pz-r-m); background: var(--pz-a50); border: 1px solid var(--pz-a100); }
|
||||
@@ -259,3 +275,4 @@
|
||||
.contrib-session-quick { justify-content: flex-end; }
|
||||
.contrib-field-label { width: 88px; }
|
||||
}
|
||||
.tm-share-btn { justify-self: start; }
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { inviteManager, invitationUrl, listManagers, removeManager, renewInvitationLink, revokeInvitation } from '../api/managersApi.js'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import { dateLocale, t } from '../i18n/index.js'
|
||||
import { initialOf } from '../utils/initial.js'
|
||||
|
||||
// Onglet Gestion des paramètres de l'arbre (issue #145). Le propriétaire invite des co-gestionnaires par leur
|
||||
// adresse : Parenthèse n'envoie pas de mail, il donne un lien à transmettre. Les co-gestionnaires voient la liste.
|
||||
|
||||
const ERRORS = {
|
||||
already_manager: t('Cette adresse gère déjà cet arbre.'),
|
||||
invalid_email: t('Cette adresse email ne semble pas valide.'),
|
||||
too_many_invitations: t('Trop d’invitations en attente. Annulez-en une avant d’en envoyer une autre.'),
|
||||
}
|
||||
|
||||
function personInitial(person) {
|
||||
return initialOf(person.firstName || person.email)
|
||||
}
|
||||
|
||||
const TreeManagersSection = ({ treeId, authToken, currentUserId = null, oidcName = '', onOpenShare }) => {
|
||||
const [state, setState] = useState({ loading: true, error: '', data: null })
|
||||
const [email, setEmail] = useState('')
|
||||
const [busy, setBusy] = useState('')
|
||||
const [message, setMessage] = useState({ error: '', success: '' })
|
||||
// Lien tout juste créé, montré en clair si le presse-papiers est refusé
|
||||
const [link, setLink] = useState(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState(null)
|
||||
// Co-gestionnaire tout juste retiré : il a pu lire le mot de passe de partage, on propose de le changer
|
||||
const [removedName, setRemovedName] = useState('')
|
||||
|
||||
const load = () => listManagers(treeId, authToken)
|
||||
.then((data) => setState({ loading: false, error: '', data }))
|
||||
.catch(() => setState({ loading: false, error: t('Impossible de charger les gestionnaires.'), data: null }))
|
||||
|
||||
useEffect(() => {
|
||||
setState({ loading: true, error: '', data: null })
|
||||
load()
|
||||
}, [treeId, authToken]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const offerLink = async (token, forEmail) => {
|
||||
const url = invitationUrl(token)
|
||||
let copied
|
||||
try { await navigator.clipboard.writeText(url); copied = true } catch { copied = false }
|
||||
setLink({ url, email: forEmail, copied })
|
||||
}
|
||||
|
||||
const handleInvite = async (event) => {
|
||||
event.preventDefault()
|
||||
if (!email.trim() || busy) return
|
||||
setBusy('invite')
|
||||
setMessage({ error: '', success: '' })
|
||||
try {
|
||||
const { token, invitation } = await inviteManager(treeId, email.trim(), authToken)
|
||||
setEmail('')
|
||||
await offerLink(token, invitation.email)
|
||||
await load()
|
||||
} catch (error) {
|
||||
setMessage({ error: ERRORS[error?.payload?.error] || t('Impossible d’envoyer l’invitation.'), success: '' })
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = async (invitation) => {
|
||||
setBusy(`link-${invitation.id}`)
|
||||
setMessage({ error: '', success: '' })
|
||||
try {
|
||||
const { token } = await renewInvitationLink(treeId, invitation.id, authToken)
|
||||
await offerLink(token, invitation.email)
|
||||
} catch {
|
||||
setMessage({ error: t('Impossible de créer le lien.'), success: '' })
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = async (invitation) => {
|
||||
setBusy(`revoke-${invitation.id}`)
|
||||
try {
|
||||
await revokeInvitation(treeId, invitation.id, authToken)
|
||||
if (link?.email === invitation.email) setLink(null)
|
||||
await load()
|
||||
} catch {
|
||||
setMessage({ error: t('Impossible d’annuler l’invitation.'), success: '' })
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (manager) => {
|
||||
setBusy(`remove-${manager.userId}`)
|
||||
try {
|
||||
await removeManager(treeId, manager.userId, authToken)
|
||||
setConfirmRemove(null)
|
||||
setMessage({ error: '', success: '' })
|
||||
setRemovedName(manager.firstName || manager.email)
|
||||
await load()
|
||||
} catch {
|
||||
setMessage({ error: t('Impossible de retirer ce co-gestionnaire.'), success: '' })
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
if (state.loading) return <p className="pz-small ts-loading">{t('Chargement…')}</p>
|
||||
if (state.error) return <div className="pz-error" role="alert">{state.error}</div>
|
||||
|
||||
const { canManage, managers, invitations } = state.data
|
||||
return (
|
||||
<div className="ts-panel">
|
||||
<p className="pz-small">{t('Les co-gestionnaires modifient l’arbre directement et valident les propositions de la famille, comme vous. Seul le propriétaire invite, retire un co-gestionnaire ou supprime l’arbre.')}</p>
|
||||
|
||||
{managers.map((manager) => {
|
||||
const isYou = manager.userId === currentUserId
|
||||
return (
|
||||
<div key={manager.userId} className="ts-row tm-row">
|
||||
<div className="tm-who">
|
||||
<span className="tm-avatar" aria-hidden="true">{personInitial(manager)}</span>
|
||||
<div className="ts-row-text">
|
||||
<strong>{isYou ? t('Vous') : (manager.firstName || manager.email)}</strong>
|
||||
<span className="tm-mail">{manager.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ts-row-actions">
|
||||
<span className="pz-tag">{manager.role === 'owner' ? t('Propriétaire') : t('Co-gestionnaire')}</span>
|
||||
{canManage && manager.role === 'admin' && (confirmRemove === manager.userId ? (
|
||||
<>
|
||||
<button type="button" className="contrib-confirm-no" onClick={() => setConfirmRemove(null)}>{t('Garder')}</button>
|
||||
<button type="button" className="contrib-confirm-yes" onClick={() => handleRemove(manager)} disabled={Boolean(busy)}>
|
||||
<PzBusy busy={busy === `remove-${manager.userId}`} busyLabel={t('Retrait en cours')}>{t('Retirer')}</PzBusy>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="contrib-pill-btn contrib-pill-btn--ghost" onClick={() => setConfirmRemove(manager.userId)}>{t('Retirer')}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{invitations.map((invitation) => (
|
||||
<div key={invitation.id} className="ts-row tm-row">
|
||||
<div className="tm-who">
|
||||
<span className="tm-avatar tm-avatar--pending" aria-hidden="true">{personInitial(invitation)}</span>
|
||||
<div className="ts-row-text">
|
||||
<strong>{invitation.email}</strong>
|
||||
<span className="tm-mail">{t('Invité le {date}, en attente', { date: new Date(invitation.createdAt).toLocaleDateString(dateLocale, { day: 'numeric', month: 'short' }) })}</span>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="ts-row-actions">
|
||||
<button type="button" className="contrib-pill-btn contrib-pill-btn--ghost" onClick={() => handleCopy(invitation)} disabled={Boolean(busy)}>
|
||||
<PzBusy busy={busy === `link-${invitation.id}`} busyLabel={t('Création du lien')}>{t('Copier le lien')}</PzBusy>
|
||||
</button>
|
||||
<button type="button" className="contrib-pill-btn contrib-pill-btn--ghost" onClick={() => handleRevoke(invitation)} disabled={Boolean(busy)}>
|
||||
<PzBusy busy={busy === `revoke-${invitation.id}`} busyLabel={t('Annulation en cours')}>{t('Annuler')}</PzBusy>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{link && (
|
||||
<div className="contrib-invite" role="status">
|
||||
<div className="contrib-invite-top">
|
||||
<span className="contrib-invite-title">{t('Lien pour {email}', { email: link.email })}</span>
|
||||
{link.copied && <span className="contrib-copy-feedback">{t('Lien copié')}</span>}
|
||||
</div>
|
||||
<div className="contrib-invite-link">
|
||||
<input className="contrib-link-url" value={link.url} readOnly aria-label={t('Lien d’invitation')} onFocus={(event) => event.target.select()} />
|
||||
</div>
|
||||
<p className="contrib-pass-hint">{t('Envoyez-le par message ou par mail. Il sert une fois et vaut 14 jours. « Copier le lien » en crée un nouveau, l’ancien ne marche plus.')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{removedName && (
|
||||
<div className="contrib-invite" role="status">
|
||||
<span className="contrib-invite-title">{t('{name} ne gère plus cet arbre.', { name: removedName })}</span>
|
||||
<p className="contrib-pass-hint">{t('Cette personne a pu voir le mot de passe de partage : avec lui, elle peut encore ouvrir l’arbre en lecture. Pour lui couper aussi la lecture, changez-le, puis donnez le nouveau à la famille.')}</p>
|
||||
{onOpenShare && (
|
||||
<button type="button" className="contrib-pill-btn contrib-pill-btn--save tm-share-btn" onClick={onOpenShare}>{t('Changer le mot de passe de partage')}</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.error && <div className="pz-error" role="alert">{message.error}</div>}
|
||||
{message.success && <div className="pz-success" role="status">{message.success}</div>}
|
||||
|
||||
{canManage && (
|
||||
<form onSubmit={handleInvite}>
|
||||
<fieldset className="ts-policy">
|
||||
<legend className="pz-eyebrow">{t('Inviter un co-gestionnaire')}</legend>
|
||||
<div className="pz-field">
|
||||
<label htmlFor="managerInviteEmail">{t('Son adresse email')}</label>
|
||||
<div className="tm-invite">
|
||||
<input
|
||||
id="managerInviteEmail"
|
||||
className="pz-input"
|
||||
type="email"
|
||||
autoComplete="off"
|
||||
placeholder={t('prenom@exemple.fr')}
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
<button type="submit" className="pz-btn pz-btn--primary" disabled={!email.trim() || Boolean(busy)}>
|
||||
<PzBusy busy={busy === 'invite'} busyLabel={t('Invitation en cours')}>{t('Inviter')}</PzBusy>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="pz-hint">
|
||||
{oidcName
|
||||
? t('Parenthèse n’envoie pas de mail : vous recevez un lien à lui transmettre. S’il se connecte avec {name} et cette adresse, il est ajouté sans le lien.', { name: oidcName })
|
||||
: t('Parenthèse n’envoie pas de mail : vous recevez un lien à lui transmettre.')}
|
||||
</p>
|
||||
</fieldset>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TreeManagersSection
|
||||
@@ -0,0 +1,34 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import PersonCard from './PersonCard.jsx'
|
||||
import AdminPanel from './AdminPanel.jsx'
|
||||
import TreeDropdown from './navbar/TreeDropdown.jsx'
|
||||
|
||||
// Co-gestion (issue #145) et « Modifier » de la fiche : ce que chaque rôle voit. Les droits eux-mêmes sont tenus
|
||||
// par le serveur (backend/test/co-gestion.db.test.ts) ; ici, l'interface ne propose pas ce qui serait refusé.
|
||||
const noop = () => {}
|
||||
const person = { id: 'p1', firstName: 'Marie', lastName: 'Martin', isAlive: true }
|
||||
|
||||
describe('fiche, « Modifier » d’un clic', () => {
|
||||
it('proposé quand App le donne (administrateur, hors démo), hors mode modification', () => {
|
||||
expect(renderToStaticMarkup(<PersonCard person={person} onQuickEdit={noop} onSave={noop} />)).toContain('pc-quick-edit')
|
||||
})
|
||||
|
||||
it('absent pour la famille et la démo (App ne le donne pas), et déjà en modification', () => {
|
||||
expect(renderToStaticMarkup(<PersonCard person={person} onSave={noop} />)).not.toContain('pc-quick-edit')
|
||||
expect(renderToStaticMarkup(<PersonCard person={person} onQuickEdit={noop} editMode onSave={noop} />)).not.toContain('pc-quick-edit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('co-gestionnaire dans les menus', () => {
|
||||
it('le menu des arbres nomme le rôle « Co-gestionnaire », plus « Admin »', () => {
|
||||
const html = renderToStaticMarkup(<TreeDropdown trees={[{ id: 't1', name: 'Arbre de démo', role: 'admin' }]} activeTreeId="t1" onSelect={noop} onCreate={noop} />)
|
||||
expect(html).toContain('Co-gestionnaire')
|
||||
expect(html).not.toContain('>Admin<')
|
||||
})
|
||||
|
||||
it('le panneau a un onglet Gestion', () => {
|
||||
const html = renderToStaticMarkup(<AdminPanel visible treeId="t1" authToken="x" isAdmin onClose={noop} />)
|
||||
expect(html).toContain('Gestion')
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { t } from '../../i18n/index.js'
|
||||
import LanguageSwitch from './LanguageSwitch'
|
||||
import { NavMenu, NavMenuGroup, NavMenuItem, NavMenuSep } from './NavMenu'
|
||||
import './AvatarMenu.css'
|
||||
import { initialOf } from '../../utils/initial.js'
|
||||
|
||||
const ICON = { size: 18, strokeWidth: 2, 'aria-hidden': true }
|
||||
|
||||
@@ -78,7 +79,7 @@ function AvatarMenu({
|
||||
) : (
|
||||
<>
|
||||
<button type="button" role="menuitem" className="navm-item navm-head" onClick={() => { onClose(); onAccountClick?.() }}>
|
||||
<span className="navm-head-mono" aria-hidden="true">{displayName.trim().charAt(0).toUpperCase()}</span>
|
||||
<span className="navm-head-mono" aria-hidden="true">{initialOf(displayName)}</span>
|
||||
<span className="navm-text">
|
||||
<strong>{displayName}</strong>
|
||||
<small>{userEmail || t('Mon compte')}</small>
|
||||
@@ -103,7 +104,7 @@ function AvatarMenu({
|
||||
return (
|
||||
<NavMenuItem
|
||||
key={tree.id}
|
||||
icon={<span className="navm-tree-mono" aria-hidden="true">{(tree.name || '?').trim().charAt(0).toUpperCase()}</span>}
|
||||
icon={<span className="navm-tree-mono" aria-hidden="true">{initialOf(tree.name)}</span>}
|
||||
label={tree.name}
|
||||
onSelect={isActive ? undefined : () => { onClose(); onSelectTree?.(tree.id) }}
|
||||
disabled={isActive}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { toJourneyRole, toOpaqueTreeId, trackAppEvent } from '../../utils/analyt
|
||||
import { t } from '../../i18n/index.js'
|
||||
import { PHONE_QUERY } from '../../utils/sheetDrag'
|
||||
import './ContextualNavbar.css'
|
||||
import { initialOf } from '../../utils/initial.js'
|
||||
|
||||
const ANNOT_TOOLS = [
|
||||
{ id: 'pointer', label: t('Sélection'), Icon: MousePointer2 },
|
||||
@@ -241,7 +242,7 @@ function ContextualNavbar({
|
||||
</NavMenu>
|
||||
)
|
||||
|
||||
const avatarInitial = (linkedPersonName || userDisplayName || userEmail || 'P').trim().charAt(0).toUpperCase()
|
||||
const avatarInitial = initialOf(linkedPersonName || userDisplayName || userEmail, 'P')
|
||||
const pendingBadge = isOwner && onShareClick && pendingContributions > 0
|
||||
|
||||
return (
|
||||
|
||||
@@ -41,7 +41,7 @@ function TreeDropdown({ trees = [], activeTreeId, onSelect, onCreate }) {
|
||||
<div className="tree-dropdown-item-info">
|
||||
<div className="tree-dropdown-item-name">{tree.name}</div>
|
||||
<div className="tree-dropdown-item-role">
|
||||
{tree.role === 'owner' ? t('Propriétaire') : t('Admin')}
|
||||
{tree.role === 'owner' ? t('Propriétaire') : t('Co-gestionnaire')}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -107,12 +107,12 @@ export function useAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegister = async (firstName, email, password) => {
|
||||
const handleRegister = async (firstName, email, password, invitationToken) => {
|
||||
setAccountLoading(true)
|
||||
setAccountError('')
|
||||
|
||||
try {
|
||||
const result = await registerUser(firstName, email, password)
|
||||
const result = await registerUser(firstName, email, password, invitationToken)
|
||||
const mergedUser = {
|
||||
...(result.user || {}),
|
||||
firstName: firstName || result.user?.firstName || '',
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Traductions anglaises, clé = texte français exact passé à t().
|
||||
// Co-gestion d'un arbre (issue #145) : onglet Gestion, page du lien d'invitation, suppression du compte
|
||||
export default {
|
||||
// Onglet Gestion (TreeManagersSection)
|
||||
'Gestion': 'Managers',
|
||||
'Co-gestionnaire': 'Co-manager',
|
||||
'Cette adresse gère déjà cet arbre.': 'This address already manages this tree.',
|
||||
'Cette adresse email ne semble pas valide.': 'This email address does not look valid.',
|
||||
'Trop d’invitations en attente. Annulez-en une avant d’en envoyer une autre.': 'Too many pending invitations. Cancel one before sending another.',
|
||||
'Impossible de charger les gestionnaires.': 'Could not load the managers.',
|
||||
'Impossible d’envoyer l’invitation.': 'Could not send the invitation.',
|
||||
'Impossible de créer le lien.': 'Could not create the link.',
|
||||
'Impossible d’annuler l’invitation.': 'Could not cancel the invitation.',
|
||||
'Impossible de retirer ce co-gestionnaire.': 'Could not remove this co-manager.',
|
||||
'Les co-gestionnaires modifient l’arbre directement et valident les propositions de la famille, comme vous. Seul le propriétaire invite, retire un co-gestionnaire ou supprime l’arbre.':
|
||||
'Co-managers edit the tree directly and review the family’s suggestions, like you. Only the owner invites or removes a co-manager, or deletes the tree.',
|
||||
'Retrait en cours': 'Removing',
|
||||
'Invité le {date}, en attente': 'Invited on {date}, pending',
|
||||
'Création du lien': 'Creating the link',
|
||||
'Copier le lien': 'Copy the link',
|
||||
'Annulation en cours': 'Cancelling',
|
||||
'Lien pour {email}': 'Link for {email}',
|
||||
'Lien d’invitation': 'Invitation link',
|
||||
'Envoyez-le par message ou par mail. Il sert une fois et vaut 14 jours. « Copier le lien » en crée un nouveau, l’ancien ne marche plus.':
|
||||
'Send it by text or email. It works once and lasts 14 days. “Copy the link” creates a new one, and the old one stops working.',
|
||||
'{name} ne gère plus cet arbre.': '{name} no longer manages this tree.',
|
||||
'Cette personne a pu voir le mot de passe de partage : avec lui, elle peut encore ouvrir l’arbre en lecture. Pour lui couper aussi la lecture, changez-le, puis donnez le nouveau à la famille.':
|
||||
'This person may have seen the sharing password, which still lets them open the tree to read it. To cut that too, change it, then give the new one to the family.',
|
||||
'Changer le mot de passe de partage': 'Change the sharing password',
|
||||
'Inviter un co-gestionnaire': 'Invite a co-manager',
|
||||
'Son adresse email': 'Their email address',
|
||||
'prenom@exemple.fr': 'name@example.com',
|
||||
'Invitation en cours': 'Inviting',
|
||||
'Inviter': 'Invite',
|
||||
'Parenthèse n’envoie pas de mail : vous recevez un lien à lui transmettre. S’il se connecte avec {name} et cette adresse, il est ajouté sans le lien.':
|
||||
'Parenthèse sends no email: you get a link to pass on. If they sign in with {name} and this address, they are added without the link.',
|
||||
'Parenthèse n’envoie pas de mail : vous recevez un lien à lui transmettre.': 'Parenthèse sends no email: you get a link to pass on.',
|
||||
|
||||
// Avancé, pour un co-gestionnaire (AdminPanel)
|
||||
'Impossible de quitter la gestion de cet arbre.': 'Could not stop managing this tree.',
|
||||
'Quitter la gestion de cet arbre': 'Stop managing this tree',
|
||||
'Vous ne pourrez plus le modifier. Seul son propriétaire peut le supprimer.': 'You will no longer be able to edit it. Only its owner can delete it.',
|
||||
'Rester': 'Stay',
|
||||
'Quitter': 'Leave',
|
||||
|
||||
// Page du lien d'invitation (InvitationScreen)
|
||||
'Ce lien a déjà servi. Si c’était le vôtre, l’arbre est dans vos arbres.': 'This link has already been used. If it was yours, the tree is in your trees.',
|
||||
'Ce lien a expiré. Demandez-en un nouveau à la personne qui vous a invité.': 'This link has expired. Ask the person who invited you for a new one.',
|
||||
'Ce lien d’invitation ne marche pas. Demandez-en un nouveau à la personne qui vous a invité.': 'This invitation link does not work. Ask the person who invited you for a new one.',
|
||||
'Invitation': 'Invitation',
|
||||
'Ouvrir Parenthèse': 'Open Parenthèse',
|
||||
'Gérer « {name} »': 'Manage “{name}”',
|
||||
'{name} vous invite à modifier cet arbre avec votre propre compte. L’invitation est pour {email}.': '{name} invites you to edit this tree with your own account. The invitation is for {email}.',
|
||||
'Vous êtes invité à modifier cet arbre avec votre propre compte. L’invitation est pour {email}.': 'You are invited to edit this tree with your own account. The invitation is for {email}.',
|
||||
'Vous êtes connecté avec {email}.': 'You are signed in as {email}.',
|
||||
'Ouverture de l’arbre': 'Opening the tree',
|
||||
'Accepter et ouvrir l’arbre': 'Accept and open the tree',
|
||||
'Changer de compte': 'Use another account',
|
||||
'J’ai déjà un compte Parenthèse': 'I already have a Parenthèse account',
|
||||
'Le lien sert une fois et vaut 14 jours.': 'The link works once and lasts 14 days.',
|
||||
'L’adresse à laquelle vous avez été invité.': 'The address you were invited at.',
|
||||
'Créer mon compte et ouvrir l’arbre': 'Create my account and open the tree',
|
||||
|
||||
// Suppression du compte (AccountDashboard)
|
||||
'« {name} » sera supprimé pour tout le monde': '“{name}” will be deleted for everyone',
|
||||
'{names} le gèrent avec vous. Ils n’y auront plus accès, la famille non plus.': '{names} manage it with you. They will lose access, and so will the family.',
|
||||
'{name} le gère avec vous. Cette personne n’y aura plus accès, la famille non plus.': '{name} manages it with you. They will lose access, and so will the family.',
|
||||
|
||||
// Fiche : droits retirés pendant une modification (PersonCard)
|
||||
"Vous ne pouvez plus modifier cet arbre. Rechargez la page pour voir vos droits actuels.": 'You can no longer edit this tree. Reload the page to see your current rights.',
|
||||
|
||||
// Erreurs (errorMessages.js)
|
||||
"Cette invitation est pour une autre adresse. Utilisez l'adresse à laquelle vous avez été invité.": 'This invitation is for another address. Use the address you were invited at.',
|
||||
"Ce lien d'invitation ne marche plus. Demandez-en un nouveau à la personne qui vous a invité.": 'This invitation link no longer works. Ask the person who invited you for a new one.',
|
||||
'Vous êtes déjà propriétaire de cet arbre.': 'You already own this tree.',
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import contribution from './contribution.js'
|
||||
import compte from './compte.js'
|
||||
import photos from './photos.js'
|
||||
import souvenir from './souvenir.js'
|
||||
import gestion from './gestion.js'
|
||||
import recherche from './recherche.js'
|
||||
|
||||
export default {
|
||||
@@ -16,6 +17,7 @@ export default {
|
||||
...compte,
|
||||
...photos,
|
||||
...souvenir,
|
||||
...gestion,
|
||||
...recherche,
|
||||
}
|
||||
|
||||
@@ -96,6 +96,19 @@ export function getAccountErrorMessage(error) {
|
||||
return t("Cette instance n'accepte plus de nouveaux comptes. Demandez à la personne qui l'héberge.")
|
||||
}
|
||||
|
||||
// Lien d'invitation à co-gérer un arbre (issue #145)
|
||||
if (errorCode === 'invitation_email_mismatch') {
|
||||
return t("Cette invitation est pour une autre adresse. Utilisez l'adresse à laquelle vous avez été invité.")
|
||||
}
|
||||
|
||||
if (errorCode === 'invitation_invalid' || errorCode === 'invitation_used' || errorCode === 'invitation_expired') {
|
||||
return t("Ce lien d'invitation ne marche plus. Demandez-en un nouveau à la personne qui vous a invité.")
|
||||
}
|
||||
|
||||
if (errorCode === 'already_owner') {
|
||||
return t('Vous êtes déjà propriétaire de cet arbre.')
|
||||
}
|
||||
|
||||
if (errorCode === 'citation_text_required') {
|
||||
return t('Ajoutez le texte de la citation.')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Premier caractère affiché d'un nom, en majuscule, pour les pastilles (arbre, compte, personne). charAt(0) coupait
|
||||
// un emoji en deux (« 🌳 Famille » donnait un losange « ? ») : on prend le premier graphème entier, emoji composés
|
||||
// compris (👨👩👧, drapeaux).
|
||||
const segmenter = typeof Intl !== 'undefined' && Intl.Segmenter ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null
|
||||
|
||||
export function initialOf(text, fallback = '?') {
|
||||
const value = String(text ?? '').trim()
|
||||
if (!value) return fallback
|
||||
const first = segmenter ? segmenter.segment(value)[Symbol.iterator]().next().value.segment : Array.from(value)[0]
|
||||
return first.toUpperCase()
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { initialOf } from './initial.js'
|
||||
|
||||
describe('initialOf', () => {
|
||||
it('donne la première lettre en majuscule', () => {
|
||||
expect(initialOf('famille Dupont')).toBe('F')
|
||||
expect(initialOf(' élise')).toBe('É')
|
||||
})
|
||||
|
||||
it('garde un emoji entier, composé ou non', () => {
|
||||
expect(initialOf('🌳 Famille')).toBe('🌳')
|
||||
expect(initialOf('👨👩👧 Les nôtres')).toBe('👨👩👧')
|
||||
expect(initialOf('🇫🇷 Côté France')).toBe('🇫🇷')
|
||||
})
|
||||
|
||||
it('rend le repli pour un nom vide', () => {
|
||||
expect(initialOf('', 'P')).toBe('P')
|
||||
expect(initialOf(null)).toBe('?')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user