From e50a8c646b5ec2ee60ec96a533e6fec63b54a58f Mon Sep 17 00:00:00 2001 From: "t.indorf" Date: Sat, 10 Oct 2026 07:26:25 +0200 Subject: [PATCH] fix: preserve invitations for admins with a family --- src/app/dashboard/families/actions.ts | 2 +- src/lib/family-invitations.ts | 5 ++--- tests/security.test.cjs | 27 +++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/app/dashboard/families/actions.ts b/src/app/dashboard/families/actions.ts index e120ef8..e18f011 100644 --- a/src/app/dashboard/families/actions.ts +++ b/src/app/dashboard/families/actions.ts @@ -466,7 +466,7 @@ export async function resendFamilyInvitationAction( if (!(await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." }; const recipient = await prisma.user.findFirst({ - where: { id: userId, kitaId: session.user.kitaId, role: UserRole.ELTERN, + where: { id: userId, kitaId: session.user.kitaId, familyId: { not: null }, emailVerifiedAt: null, passwordHash: "", disabledAt: null }, select: { email: true }, }); diff --git a/src/lib/family-invitations.ts b/src/lib/family-invitations.ts index d4f279f..bf2826d 100644 --- a/src/lib/family-invitations.ts +++ b/src/lib/family-invitations.ts @@ -15,7 +15,7 @@ export async function queueFamilyInvitation( invitedById: string, ) { const user = await tx.user.findUniqueOrThrow({ where: { id: userId } }); - if (!user.kitaId || !user.familyId || user.role !== "ELTERN" || user.emailVerifiedAt || user.passwordHash || user.disabledAt) + if (!user.kitaId || !user.familyId || user.emailVerifiedAt || user.passwordHash || user.disabledAt) throw new Error("Dieses Konto benötigt keine Familieneinladung."); const existing = await tx.invitation.findFirst({ where: { @@ -99,7 +99,6 @@ export async function deliverFamilyInvitations( user.emailVerifiedAt || user.disabledAt || user.passwordHash !== "" || - user.role !== "ELTERN" || !token || token.identifier !== user.id || token.expires <= now @@ -170,7 +169,7 @@ export async function resendFamilyInvitation( where: { id: userId, kitaId, familyId: { not: null } }, }); if (!user) return { error: "Elternkonto wurde nicht gefunden." }; - if (user.emailVerifiedAt || user.passwordHash || user.disabledAt || user.role !== "ELTERN") + if (user.emailVerifiedAt || user.passwordHash || user.disabledAt) return { error: "Keine offene Einladung gefunden." }; if (expectedEmail && user.email !== expectedEmail) return { error: "Die Einladung hat sich geändert. Bitte lade die Seite neu." }; diff --git a/tests/security.test.cjs b/tests/security.test.cjs index dddaede..d9cd702 100644 --- a/tests/security.test.cjs +++ b/tests/security.test.cjs @@ -315,6 +315,33 @@ test('changing a parent email revokes sessions and requires fresh mailbox activa assert.equal((await accept({ attempt: 0 }, form(renewedInvite))).errors,undefined); assert.ok((await prisma.user.findUnique({ where: { id: parentA.id } })).emailVerifiedAt); }); +test('family admins keep their role when a changed mailbox is invited and activated', async () => { + const family = await prisma.family.create({ data: { kitaId: kitaA.id, name: 'Admin family' } }); + const child = await prisma.child.create({ data: { kitaId: kitaA.id, familyId: family.id, firstName: 'Admin', lastName: 'Child' } }); + const user = await prisma.user.create({ data: { email: `family-admin.${prefix}@example.invalid`, firstName: 'Family', lastName: 'Admin', + role: 'ADMIN', kitaId: kitaA.id, familyId: family.id, passwordHash: passHash, + emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } }); + try { + const load = loader({ '@/auth': { auth: async () => session(adminA), signIn: async () => {} }, '@/lib/mail': emailMock }); + const actions = load('src/app/dashboard/families/actions.ts'); + const updated = await actions.updateFamilyAction({ familyId: family.id, familyName: 'Family A', + parents: [{ id: user.id, firstName: 'Family', lastName: 'Admin', email: `changed-family-admin.${prefix}@example.invalid` }], + children: [{ id: child.id, firstName: 'A', lastName: 'Test' }] }); + assert.equal(updated.success,true,updated.error); assert.equal(updated.warning,undefined); + assert.equal((await prisma.user.findUnique({ where: { id: user.id } })).role,'ADMIN'); + await prisma.invitation.update({ where: { userId: user.id }, data: { sentAt: new Date(Date.now() - 61_000) } }); + assert.equal((await actions.resendParentInvite(user.id)).success,true); + const token = emails.at(-1).react.props.inviteLink.split('/').at(-1); + const accepted = await load('src/app/invite/[token]/actions.ts').acceptInviteAction({ attempt: 0 }, fields({ token, + password: 'Family-admin-new-password', confirmPassword: 'Family-admin-new-password', acceptPrivacyPolicy: 'on' })); + assert.equal(accepted.errors,undefined); + const activated = await prisma.user.findUnique({ where: { id: user.id } }); + assert.equal(activated.role,'ADMIN'); assert.ok(activated.emailVerifiedAt); + } finally { + await prisma.verificationToken.deleteMany({ where: { identifier: user.id } }); + await prisma.family.delete({ where: { id: family.id } }); + } +}); test('resending an invite cannot issue an active link to an obsolete mailbox', async () => { const user = await prisma.user.create({ data: { email: `resend-race.${prefix}@example.invalid`, firstName: 'Race', lastName: 'Test', role: 'ELTERN', kitaId: kitaA.id, familyId: familyA.id, passwordHash: '' } });