From 8ab5df675e98129bb57b106ee331a8d07d324a45 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 23 Nov 2025 10:13:15 +0000 Subject: fix: hide collaborator emails from non-owners (#2160) * feat: Hide collaborator emails from non-owners in shared lists Implemented privacy protection for collaborator emails in shared lists. Non-owners (viewers and editors) can no longer see email addresses of the list owner or other collaborators. Only the list owner can view all email addresses. Changes: - Modified List.getCollaborators() to return empty strings for emails when the requester is not the owner - Updated ManageCollaboratorsModal UI to conditionally display email fields only when they are not empty - Added comprehensive test to verify email privacy for non-owners while ensuring owners can still see all emails This follows existing privacy patterns in the codebase (similar to how pending invitation names are masked as "Pending User"). * make the email field nullable * fix tests --------- Co-authored-by: Claude --- packages/trpc/models/lists.ts | 6 +- packages/trpc/routers/lists.ts | 4 +- packages/trpc/routers/sharedLists.test.ts | 93 +++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) (limited to 'packages') diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts index a0d9ca23..0968492a 100644 --- a/packages/trpc/models/lists.ts +++ b/packages/trpc/models/lists.ts @@ -752,7 +752,8 @@ export abstract class List { user: { id: c.user.id, name: c.user.name, - email: c.user.email, + // Only show email to the owner for privacy + email: isOwner ? c.user.email : null, }, }; }); @@ -763,7 +764,8 @@ export abstract class List { ? { id: owner.id, name: owner.name, - email: owner.email, + // Only show owner email to the owner for privacy + email: isOwner ? owner.email : null, } : null, }; diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts index 5eb0baff..296679f3 100644 --- a/packages/trpc/routers/lists.ts +++ b/packages/trpc/routers/lists.ts @@ -301,7 +301,7 @@ export const listsAppRouter = router({ user: z.object({ id: z.string(), name: z.string(), - email: z.string(), + email: z.string().nullable(), }), }), ), @@ -309,7 +309,7 @@ export const listsAppRouter = router({ .object({ id: z.string(), name: z.string(), - email: z.string(), + email: z.string().nullable(), }) .nullable(), }), diff --git a/packages/trpc/routers/sharedLists.test.ts b/packages/trpc/routers/sharedLists.test.ts index 58a24d46..3440fae4 100644 --- a/packages/trpc/routers/sharedLists.test.ts +++ b/packages/trpc/routers/sharedLists.test.ts @@ -2831,5 +2831,98 @@ describe("Shared Lists", () => { // Email should still be visible to owner expect(declinedInvitation?.user.email).toBe(collaboratorEmail); }); + + test("should hide emails from non-owners", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaborator1Api = apiCallers[1]; + const collaborator2Api = apiCallers[2]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const ownerUser = await ownerApi.users.whoami(); + const ownerEmail = ownerUser.email!; + + const collaborator1User = await collaborator1Api.users.whoami(); + const collaborator1Email = collaborator1User.email!; + + const collaborator2User = await collaborator2Api.users.whoami(); + const collaborator2Email = collaborator2User.email!; + + // Add both collaborators + await addAndAcceptCollaborator( + ownerApi, + collaborator1Api, + list.id, + "editor", + ); + await addAndAcceptCollaborator( + ownerApi, + collaborator2Api, + list.id, + "viewer", + ); + + // Owner should see all emails + const ownerView = await ownerApi.lists.getCollaborators({ + listId: list.id, + }); + + expect(ownerView.owner?.email).toBe(ownerEmail); + + const ownerViewCollaborators = ownerView.collaborators.filter( + (c) => c.status === "accepted", + ); + expect(ownerViewCollaborators).toHaveLength(2); + + const ownerViewCollab1 = ownerViewCollaborators.find( + (c) => c.user.email === collaborator1Email, + ); + const ownerViewCollab2 = ownerViewCollaborators.find( + (c) => c.user.email === collaborator2Email, + ); + + expect(ownerViewCollab1?.user.email).toBe(collaborator1Email); + expect(ownerViewCollab2?.user.email).toBe(collaborator2Email); + + // Non-owners should NOT see any emails + const collaborator1View = await collaborator1Api.lists.getCollaborators({ + listId: list.id, + }); + + // Should not see owner email + expect(collaborator1View.owner?.email).toBe(null); + + // Should not see other collaborators' emails + const collab1ViewCollaborators = collaborator1View.collaborators.filter( + (c) => c.status === "accepted", + ); + expect(collab1ViewCollaborators).toHaveLength(2); + + collab1ViewCollaborators.forEach((c) => { + expect(c.user.email).toBe(null); + }); + + // Verify collaborator2 also can't see emails + const collaborator2View = await collaborator2Api.lists.getCollaborators({ + listId: list.id, + }); + + expect(collaborator2View.owner?.email).toBe(null); + + const collab2ViewCollaborators = collaborator2View.collaborators.filter( + (c) => c.status === "accepted", + ); + expect(collab2ViewCollaborators).toHaveLength(2); + + collab2ViewCollaborators.forEach((c) => { + expect(c.user.email).toBe(null); + }); + }); }); }); -- cgit v1.2.3-70-g09d2