mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-15 08:32:03 +00:00
Co-gestion d'un arbre (#145) : invitations, rattachement OIDC sur email vérifié, compte ouvert par invitation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
befe8fe19a
commit
5be134e0e6
12 files changed
+677
-8
No files matched your search
@@ -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 { mediaRoutes } from './routes/media.js'
|
||||
import { oidcRoutes } from './routes/oidc.js'
|
||||
import { publicRoutes } from './routes/public.js'
|
||||
@@ -159,6 +160,7 @@ export function createApp() {
|
||||
app.register(gedcomRoutes)
|
||||
app.register(visitRoutes)
|
||||
app.register(immichRoutes)
|
||||
app.register(managerRoutes)
|
||||
|
||||
app.setErrorHandler((error: FastifyError | Error, request, reply) => {
|
||||
if (error instanceof Prisma.PrismaClientInitializationError) {
|
||||
|
||||
@@ -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,116 @@
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
|
||||
import type { Prisma, PrismaClient } from '@prisma/client'
|
||||
|
||||
// Co-gestion d'un arbre (issue #145). Le propriétaire invite une adresse ; Parenthèse n'envoie pas de mail, il
|
||||
// donne un lien à transmettre. Seule l'empreinte du jeton est gardée en base.
|
||||
//
|
||||
// Qui est rattaché, et comment :
|
||||
// - par le lien, avec un compte à la même adresse (mot de passe ou OIDC). L'inscription par mot de passe ne
|
||||
// vérifie pas l'adresse : le lien est la preuve, l'adresse seule ne suffit jamais ;
|
||||
// - sans le lien, seulement par OIDC, quand le fournisseur dit l'email vérifié (claimInvitationsForVerifiedEmail).
|
||||
|
||||
export const INVITATION_TTL_MS = 14 * 24 * 60 * 60 * 1000
|
||||
export const MAX_PENDING_INVITATIONS = 20
|
||||
|
||||
type Db = PrismaClient | Prisma.TransactionClient
|
||||
|
||||
export function newInvitationToken(): { token: string; tokenHash: string } {
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
return { token, tokenHash: hashInvitationToken(token) }
|
||||
}
|
||||
|
||||
export function hashInvitationToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase()
|
||||
}
|
||||
|
||||
// Invitation encore valable : ni acceptée, ni annulée, ni expirée
|
||||
export function pendingInvitationWhere(now = new Date()): Prisma.TreeInvitationWhereInput {
|
||||
return { acceptedAt: null, revokedAt: null, expiresAt: { gt: now }, tree: { deletedAt: null } }
|
||||
}
|
||||
|
||||
export async function findPendingInvitationByToken(db: Db, token: string) {
|
||||
if (!token || token.length > 200) return null
|
||||
return db.treeInvitation.findFirst({
|
||||
where: { tokenHash: hashInvitationToken(token), ...pendingInvitationWhere() },
|
||||
include: { tree: { select: { id: true, name: true, ownerUserId: true, owner: { select: { firstName: true } } } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function hasPendingInvitationForEmail(db: Db, email: string): Promise<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.
|
||||
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 } })
|
||||
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,20 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
email: true,
|
||||
firstName: true,
|
||||
},
|
||||
}))
|
||||
}), { invited: Boolean(invitation) })
|
||||
// Un autre compte est né pendant le calcul du hash, sur une instance fermée
|
||||
if (!user) {
|
||||
return reply.code(403).send({ error: 'registration_closed' })
|
||||
}
|
||||
|
||||
return reply.code(201).send({ token: signUserSession(app, user), user: { ...user, hasPassword: true } })
|
||||
// Compte ouvert par une invitation : co-gestionnaire de l'arbre tout de suite
|
||||
let joinedTreeId: string | null = null
|
||||
if (invitation) {
|
||||
const accepted = await acceptInvitation(app.prisma, invitation.id, user.id, user.email)
|
||||
if (accepted.ok) joinedTreeId = accepted.treeId
|
||||
}
|
||||
|
||||
return reply.code(201).send({ token: signUserSession(app, user), user: { ...user, hasPassword: true }, joinedTreeId })
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
import { isOidcEnabled } from '../lib/oidc.js'
|
||||
import {
|
||||
INVITATION_TTL_MS,
|
||||
MAX_PENDING_INVITATIONS,
|
||||
acceptInvitation,
|
||||
findPendingInvitationByToken,
|
||||
maskEmail,
|
||||
newInvitationToken,
|
||||
normalizeEmail,
|
||||
pendingInvitationWhere,
|
||||
} from '../lib/tree-invitations.js'
|
||||
|
||||
// Co-gestion d'un arbre (issue #145). Un co-gestionnaire est un membre « admin » : il fait tout ce que fait le
|
||||
// propriétaire, sauf inviter ou retirer un co-gestionnaire et supprimer l'arbre (DELETE /trees/:id, réservé au
|
||||
// propriétaire). Les droits sont relus en base à chaque requête : un co-gestionnaire retiré perd la main aussitôt.
|
||||
|
||||
const treeIdParamSchema = z.object({ id: z.string().min(1) })
|
||||
const invitationParamsSchema = z.object({ id: z.string().min(1), invitationId: z.string().min(1) })
|
||||
const managerParamsSchema = z.object({ id: z.string().min(1), userId: z.string().min(1) })
|
||||
const tokenParamSchema = z.object({ token: z.string().min(1).max(200) })
|
||||
const inviteSchema = z.object({ email: z.string().trim().email().max(254) })
|
||||
|
||||
function requireUser(request: FastifyRequest, reply: FastifyReply): { userId: string } | null {
|
||||
if (!request.actor || request.actor.kind !== 'user') {
|
||||
reply.code(401).send({ error: 'authentication_required' })
|
||||
return null
|
||||
}
|
||||
return { userId: request.actor.userId }
|
||||
}
|
||||
|
||||
async function membershipOf(app: FastifyInstance, treeId: string, userId: string) {
|
||||
const membership = await app.prisma.treeMembership.findUnique({
|
||||
where: { treeId_userId: { treeId, userId } },
|
||||
select: { role: true, tree: { select: { deletedAt: true, ownerUserId: true } } },
|
||||
})
|
||||
return membership && !membership.tree.deletedAt ? membership : null
|
||||
}
|
||||
|
||||
function audit(app: FastifyInstance, treeId: string, actorId: string, action: string, payloadJson: Record<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' })
|
||||
}
|
||||
|
||||
const removed = await app.prisma.treeMembership.deleteMany({
|
||||
where: { treeId: params.data.id, userId: params.data.userId, role: 'admin' },
|
||||
})
|
||||
if (removed.count !== 1) return reply.code(404).send({ error: 'manager_not_found' })
|
||||
await audit(app, params.data.id, user.userId, leaving ? 'manager_left' : 'manager_removed', { userId: params.data.userId })
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
|
||||
// Page du lien, sans compte : l'arbre, qui invite, l'adresse masquée, et les façons d'entrer sur ce serveur
|
||||
app.get('/invitations/:token', { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (request, reply) => {
|
||||
const params = tokenParamSchema.safeParse(request.params)
|
||||
if (!params.success) return reply.code(404).send({ error: 'invitation_invalid' })
|
||||
const invitation = await findPendingInvitationByToken(app.prisma, params.data.token)
|
||||
if (!invitation) return reply.code(404).send({ error: 'invitation_invalid' })
|
||||
return reply.send({
|
||||
treeName: invitation.tree.name,
|
||||
invitedBy: invitation.tree.owner.firstName || null,
|
||||
emailHint: maskEmail(invitation.email),
|
||||
expiresAt: invitation.expiresAt,
|
||||
passwordLogin: env.PASSWORD_LOGIN,
|
||||
oidc: isOidcEnabled() ? { name: env.OIDC_PROVIDER_NAME } : null,
|
||||
})
|
||||
})
|
||||
|
||||
// Accepter avec le compte connecté : il doit avoir l'adresse invitée
|
||||
app.post('/invitations/:token/accept', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => {
|
||||
const user = requireUser(request, reply)
|
||||
if (!user) return
|
||||
const params = tokenParamSchema.safeParse(request.params)
|
||||
if (!params.success) return reply.code(404).send({ error: 'invitation_invalid' })
|
||||
|
||||
const invitation = await findPendingInvitationByToken(app.prisma, params.data.token)
|
||||
if (!invitation) return reply.code(404).send({ error: 'invitation_invalid' })
|
||||
const account = await app.prisma.user.findUnique({ where: { id: user.userId }, select: { email: true } })
|
||||
if (!account) return reply.code(401).send({ error: 'authentication_required' })
|
||||
|
||||
const result = await acceptInvitation(app.prisma, invitation.id, user.userId, account.email)
|
||||
if (!result.ok) {
|
||||
const code = result.error === 'invitation_invalid' ? 404 : result.error === 'already_owner' ? 409 : 403
|
||||
return reply.code(code).send({ error: result.error })
|
||||
}
|
||||
return reply.send({ treeId: result.treeId, role: 'admin' })
|
||||
})
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../lib/oidc.js'
|
||||
import { confirmsWithPassword } from '../lib/auth.js'
|
||||
import { createAccountIfAllowed } from '../lib/registration.js'
|
||||
import { claimInvitationsForVerifiedEmail, hasPendingInvitationForEmail } from '../lib/tree-invitations.js'
|
||||
import { signUserSession } from '../lib/user-session.js'
|
||||
|
||||
// Parcours : /auth/oidc/start pose un cookie signé (state, nonce, vérificateur PKCE) et part chez le fournisseur ;
|
||||
@@ -86,6 +87,7 @@ async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Pro
|
||||
return { userId: existing.id }
|
||||
}
|
||||
|
||||
// Une invitation à co-gérer un arbre ouvre un compte même inscription fermée, si le fournisseur garantit l'adresse
|
||||
const created = await createAccountIfAllowed(app.prisma, (tx) => tx.user.create({
|
||||
data: {
|
||||
email: identity.email as string,
|
||||
@@ -94,7 +96,7 @@ async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Pro
|
||||
identities: { create: { issuer: identity.issuer, subject: identity.subject } },
|
||||
},
|
||||
select: { id: true },
|
||||
}))
|
||||
}), { invited: identity.emailVerified && await hasPendingInvitationForEmail(app.prisma, identity.email) })
|
||||
return created ? { userId: created.id } : { error: 'registration_closed' }
|
||||
}
|
||||
|
||||
@@ -179,6 +181,10 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
|
||||
if ('error' in result) {
|
||||
return redirectToApp(reply, returnTo, `oidc_error=${result.error}`)
|
||||
}
|
||||
// Invitations à co-gérer un arbre : acceptées sans le lien, l'adresse étant garantie par le fournisseur
|
||||
if (identity.email && identity.emailVerified) {
|
||||
await claimInvitationsForVerifiedEmail(app.prisma, result.userId, identity.email)
|
||||
}
|
||||
|
||||
const handoff = app.jwt.sign(
|
||||
{ kind: 'oidc_handoff', sub: result.userId, jti: randomUUID() },
|
||||
|
||||
@@ -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,225 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Co-gestion d'un arbre (#145), sur une vraie base : une fonction de droits se vérifie avec de vraies requêtes,
|
||||
// pas avec des mocks. Lancé seulement avec TEST_DATABASE_URL (base jetable, migrée) :
|
||||
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/co-gestion.db.test.ts
|
||||
const TEST_DB = process.env.TEST_DATABASE_URL
|
||||
|
||||
const { oidcMock } = vi.hoisted(() => ({ oidcMock: { startAuthorization: vi.fn(), finishAuthorization: vi.fn() } }))
|
||||
vi.mock('../src/lib/oidc.js', async (importOriginal) => ({
|
||||
...(await importOriginal<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 prisma.treeInvitation.findUnique({ where: { id: invitation.id } }))?.acceptedUserId).toBe(co.id)
|
||||
|
||||
// Co-gestionnaire : modifie l'arbre, voit la liste, mais n'invite pas, ne retire personne, ne supprime pas l'arbre
|
||||
expect((await call('PATCH', `/trees/${treeId}`, co.token, { description: 'Par la co-gestionnaire' })).status).toBe(200)
|
||||
const list = await call('GET', `/trees/${treeId}/managers`, co.token)
|
||||
expect(list.body.canManage).toBe(false)
|
||||
expect(list.body.managers.map((m: { role: string }) => m.role).sort()).toEqual(['admin', 'owner'])
|
||||
expect((await call('POST', `/trees/${treeId}/invitations`, co.token, { email: mail('autre') })).status).toBe(403)
|
||||
expect((await call('DELETE', `/trees/${treeId}/managers/${owner.id}`, co.token)).status).toBe(403)
|
||||
expect((await call('DELETE', `/trees/${treeId}`, co.token, { confirmName: 'Arbre de démo' })).status).toBe(403)
|
||||
|
||||
// Le propriétaire le retire : son jeton de session ne sert plus à rien sur l'arbre, tout de suite
|
||||
expect((await call('DELETE', `/trees/${treeId}/managers/${co.id}`, owner.token)).status).toBe(200)
|
||||
expect((await call('PATCH', `/trees/${treeId}`, co.token, { description: 'Encore moi' })).status).toBe(403)
|
||||
expect((await call('GET', `/trees/${treeId}/managers`, co.token)).status).toBe(403)
|
||||
})
|
||||
|
||||
it('un co-gestionnaire quitte seul, et ne retire jamais un autre co-gestionnaire', async () => {
|
||||
const a = await account('gestion-a')
|
||||
const b = await account('gestion-b')
|
||||
for (const who of [a, b]) {
|
||||
const { token } = await invite(who.email)
|
||||
expect((await call('POST', `/invitations/${token}/accept`, who.token)).status).toBe(200)
|
||||
}
|
||||
expect((await call('DELETE', `/trees/${treeId}/managers/${b.id}`, a.token)).status).toBe(403)
|
||||
expect((await call('DELETE', `/trees/${treeId}/managers/${a.id}`, a.token)).status).toBe(200)
|
||||
expect((await call('PATCH', `/trees/${treeId}`, a.token, { description: 'x' })).status).toBe(403)
|
||||
expect((await call('PATCH', `/trees/${treeId}`, b.token, { description: 'y' })).status).toBe(200)
|
||||
// Le propriétaire ne se retire pas lui-même
|
||||
expect((await call('DELETE', `/trees/${treeId}/managers/${owner.id}`, owner.token)).status).toBe(403)
|
||||
await call('DELETE', `/trees/${treeId}/managers/${b.id}`, owner.token)
|
||||
})
|
||||
|
||||
it('« Copier le lien » remplace le lien ; annulé ou expiré, il ne sert plus', async () => {
|
||||
const email = mail('rotation')
|
||||
const first = await invite(email)
|
||||
const rotated = await call('POST', `/trees/${treeId}/invitations/${first.invitation.id}/link`, owner.token)
|
||||
expect(rotated.status).toBe(200)
|
||||
expect((await call('GET', `/invitations/${first.token}`)).status).toBe(404)
|
||||
expect((await call('GET', `/invitations/${rotated.body.token}`)).status).toBe(200)
|
||||
|
||||
expect((await call('DELETE', `/trees/${treeId}/invitations/${first.invitation.id}`, owner.token)).status).toBe(200)
|
||||
const user = await account('rotation')
|
||||
expect((await call('POST', `/invitations/${rotated.body.token}/accept`, user.token)).status).toBe(404)
|
||||
|
||||
const late = await invite(email)
|
||||
await prisma.treeInvitation.update({ where: { id: late.invitation.id }, data: { expiresAt: new Date(Date.now() - 1000) } })
|
||||
expect((await call('POST', `/invitations/${late.token}/accept`, user.token)).status).toBe(404)
|
||||
})
|
||||
|
||||
it("inscription fermée : l'invitation ouvre un compte, à l'adresse invitée seulement", async () => {
|
||||
const email = mail('ferme')
|
||||
const { token } = await invite(email)
|
||||
env.ALLOW_REGISTRATION = false
|
||||
|
||||
expect((await register('sans-invitation')).body.error).toBe('registration_closed')
|
||||
expect((await register('autre-adresse', { invitationToken: token })).body.error).toBe('invitation_email_mismatch')
|
||||
expect((await register('x', { invitationToken: 'faux-jeton' })).status).toBe(410)
|
||||
|
||||
const created = await register('ferme', { invitationToken: token })
|
||||
expect(created.status).toBe(201)
|
||||
expect(created.body.joinedTreeId).toBe(treeId)
|
||||
expect((await call('PATCH', `/trees/${treeId}`, created.body.token, { description: 'z' })).status).toBe(200)
|
||||
// Le lien a servi : il n'ouvre pas un second compte
|
||||
expect((await call('POST', '/auth/register', undefined, { email: mail('ferme2'), password: 'mot-de-passe-solide', invitationToken: token })).status).toBe(410)
|
||||
await call('DELETE', `/trees/${treeId}/managers/${created.body.user.id}`, owner.token)
|
||||
})
|
||||
|
||||
describe('OIDC', () => {
|
||||
let address = 0
|
||||
async function oidcLogin(identity: { subject: string; email: string; emailVerified: boolean }) {
|
||||
const remoteAddress = `10.9.0.${++address}`
|
||||
oidcMock.startAuthorization.mockImplementation(async (returnTo: string) => ({
|
||||
url: new URL(`${ISSUER}/authorize?state=s`),
|
||||
pending: { state: 's', nonce: 'n', codeVerifier: 'v'.repeat(43), returnTo },
|
||||
}))
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ issuer: ISSUER, firstName: null, ...identity })
|
||||
const start = await app.inject({ method: 'GET', url: '/auth/oidc/start?returnTo=/', remoteAddress })
|
||||
const cookie = String(start.headers['set-cookie']).split(';')[0]
|
||||
const back = await app.inject({ method: 'GET', url: '/auth/oidc/callback?code=c&state=s', headers: { cookie }, remoteAddress })
|
||||
return new URLSearchParams(String(back.headers.location).split('#')[1] ?? '')
|
||||
}
|
||||
|
||||
it("email vérifié par le fournisseur : rattaché sans le lien, même inscription fermée", async () => {
|
||||
const email = mail('oidc-ok')
|
||||
await invite(email)
|
||||
env.ALLOW_REGISTRATION = false
|
||||
const fragment = await oidcLogin({ subject: `sub-ok-${run}`, email, emailVerified: true })
|
||||
expect(fragment.get('oidc')).toBeTruthy()
|
||||
const user = await prisma.user.findUnique({ where: { email }, select: { id: true } })
|
||||
const membership = await prisma.treeMembership.findUnique({ where: { treeId_userId: { treeId, userId: user!.id } } })
|
||||
expect(membership?.role).toBe('admin')
|
||||
})
|
||||
|
||||
it("email non vérifié : jamais rattaché, et pas de compte sur une instance fermée", async () => {
|
||||
const email = mail('oidc-non-verifie')
|
||||
await invite(email)
|
||||
env.ALLOW_REGISTRATION = false
|
||||
const fragment = await oidcLogin({ subject: `sub-nv-${run}`, email, emailVerified: false })
|
||||
expect(fragment.get('oidc_error')).toBe('oidc_email_unverified')
|
||||
expect(await prisma.user.findUnique({ where: { email } })).toBeNull()
|
||||
})
|
||||
|
||||
it("email non vérifié sur un compte déjà relié : l'invitation reste en attente", async () => {
|
||||
const email = mail('oidc-relie')
|
||||
const subject = `sub-relie-${run}`
|
||||
expect((await oidcLogin({ subject, email, emailVerified: true })).get('oidc')).toBeTruthy()
|
||||
await invite(email)
|
||||
expect((await oidcLogin({ subject, email, emailVerified: false })).get('oidc')).toBeTruthy()
|
||||
const user = await prisma.user.findUnique({ where: { email }, select: { id: true } })
|
||||
expect(await prisma.treeMembership.findUnique({ where: { treeId_userId: { treeId, userId: user!.id } } })).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user