diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-11-23 00:54:38 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-11-23 00:54:38 +0000 |
| commit | 5f0934acc0f7dde119be9f0a42a42742ec128377 (patch) | |
| tree | f13bd90961eab0c694eed101db0eea96e0fc4725 /packages/trpc/routers | |
| parent | daee8e7a4f764f188e1773a9def1542513bf66e1 (diff) | |
| download | karakeep-5f0934acc0f7dde119be9f0a42a42742ec128377.tar.zst | |
feat: Add invitation approval for shared lists (#2152)
* feat: Add invitation approval system for collaborative lists
- Add database schema changes to support pending invitations
- Add status field (pending/accepted/declined) to listCollaborators
- Add invitedAt and invitedEmail fields for tracking
- Add index on status for efficient queries
- Update List model with invitation workflow methods
- Modify addCollaboratorByEmail to create pending invitations
- Add acceptInvitation() for users to accept invites
- Add declineInvitation() for users to decline invites
- Add revokeInvitation() for owners to revoke pending invites
- Add getPendingInvitations() to get user's pending invites
- Implement privacy protection for pending invitations
- Mask user names as "Pending User" until invitation is accepted
- Only show email to list owner for pending invitations
- Update getSharedWithUser to only include accepted collaborations
- Ensures lists only appear after invitation is accepted
* feat: Add tRPC procedures and email notifications for list invitations
- Add new tRPC procedures for invitation workflow
- acceptInvitation: Allow users to accept pending invitations
- declineInvitation: Allow users to decline invitations
- revokeInvitation: Allow owners to revoke pending invitations
- getPendingInvitations: Get all pending invitations for current user
- Update getCollaborators output schema
- Add status, invitedAt fields to collaborator objects
- Support privacy-masked user info for pending invitations
- Add sendListInvitationEmail function
- Email notification when user is invited to collaborate
- Includes list name, inviter name, and link to view invitation
- Gracefully handles missing SMTP configuration
- Integrate email sending into invitation workflow
- Send email when new invitation is created
- Send email when declined invitation is renewed
- Catch and log errors without failing the invitation
* feat: Add UI for list invitation approval workflow
- Update ManageCollaboratorsModal to support pending invitations
- Show "Pending" badge for pending invitations
- Add revoke button for owners to cancel pending invitations
- Update success message to reflect invitation sent
- Disable role change and remove buttons for pending invitations
- Create PendingInvitationsCard component
- Display all pending invitations for the current user
- Show list name, description, inviter, and role
- Provide Accept and Decline buttons
- Auto-hide when no pending invitations exist
- Add PendingInvitationsCard to lists page
- Show at the top of the lists page
- Only renders when user has pending invitations
* fix: Add missing translation keys and fix TypeScript errors
- Add translation keys for invitation system
- lists.collaborators.invitation_sent
- lists.collaborators.pending
- lists.collaborators.revoke
- lists.collaborators.invitation_revoked
- lists.collaborators.failed_to_revoke
- lists.invitations.* (all invitation-related keys)
- Fix TypeScript errors in email sending
- Handle optional user.name with fallback to 'A user'
* wip
* fixes
* more fixes
* fix revoke
* more improvements
* comment fix
* fix email url
* fix schemas
* split pending invites into components
* more fixes
* test
* test fixes
---------
Co-authored-by: Claude <noreply@anthropic.com>
Diffstat (limited to 'packages/trpc/routers')
| -rw-r--r-- | packages/trpc/routers/lists.ts | 92 | ||||
| -rw-r--r-- | packages/trpc/routers/sharedLists.test.ts | 1461 |
2 files changed, 1278 insertions, 275 deletions
diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts index c9a19f30..5eb0baff 100644 --- a/packages/trpc/routers/lists.ts +++ b/packages/trpc/routers/lists.ts @@ -10,6 +10,7 @@ import { import type { AuthedContext } from "../index"; import { authedProcedure, createRateLimitMiddleware, router } from "../index"; +import { ListInvitation } from "../models/listInvitations"; import { List } from "../models/lists"; import { ensureBookmarkOwnership } from "./bookmarks"; @@ -47,6 +48,22 @@ export const ensureListAtLeastOwner = experimental_trpcMiddleware<{ }); }); +export const ensureInvitationAccess = experimental_trpcMiddleware<{ + ctx: AuthedContext; + input: { invitationId: string }; +}>().create(async (opts) => { + const invitation = await ListInvitation.fromId( + opts.ctx, + opts.input.invitationId, + ); + return opts.next({ + ctx: { + ...opts.ctx, + invitation, + }, + }); +}); + export const listsAppRouter = router({ create: authedProcedure .input(zNewBookmarkListSchema) @@ -218,6 +235,11 @@ export const listsAppRouter = router({ role: z.enum(["viewer", "editor"]), }), ) + .output( + z.object({ + invitationId: z.string(), + }), + ) .use( createRateLimitMiddleware({ name: "lists.addCollaborator", @@ -228,7 +250,12 @@ export const listsAppRouter = router({ .use(ensureListAtLeastViewer) .use(ensureListAtLeastOwner) .mutation(async ({ input, ctx }) => { - await ctx.list.addCollaboratorByEmail(input.email, input.role); + return { + invitationId: await ctx.list.addCollaboratorByEmail( + input.email, + input.role, + ), + }; }), removeCollaborator: authedProcedure .input( @@ -268,7 +295,9 @@ export const listsAppRouter = router({ id: z.string(), userId: z.string(), role: z.enum(["viewer", "editor"]), + status: z.enum(["pending", "accepted", "declined"]), addedAt: z.date(), + invitedAt: z.date(), user: z.object({ id: z.string(), name: z.string(), @@ -290,6 +319,67 @@ export const listsAppRouter = router({ return await ctx.list.getCollaborators(); }), + acceptInvitation: authedProcedure + .input( + z.object({ + invitationId: z.string(), + }), + ) + .use(ensureInvitationAccess) + .mutation(async ({ ctx }) => { + await ctx.invitation.accept(); + }), + + declineInvitation: authedProcedure + .input( + z.object({ + invitationId: z.string(), + }), + ) + .use(ensureInvitationAccess) + .mutation(async ({ ctx }) => { + await ctx.invitation.decline(); + }), + + revokeInvitation: authedProcedure + .input( + z.object({ + invitationId: z.string(), + }), + ) + .use(ensureInvitationAccess) + .mutation(async ({ ctx }) => { + await ctx.invitation.revoke(); + }), + + getPendingInvitations: authedProcedure + .output( + z.array( + z.object({ + id: z.string(), + listId: z.string(), + role: z.enum(["viewer", "editor"]), + invitedAt: z.date(), + list: z.object({ + id: z.string(), + name: z.string(), + icon: z.string(), + description: z.string().nullable(), + owner: z + .object({ + id: z.string(), + name: z.string(), + email: z.string(), + }) + .nullable(), + }), + }), + ), + ) + .query(async ({ ctx }) => { + return ListInvitation.pendingForUser(ctx); + }), + leaveList: authedProcedure .input( z.object({ diff --git a/packages/trpc/routers/sharedLists.test.ts b/packages/trpc/routers/sharedLists.test.ts index 3b95a033..58a24d46 100644 --- a/packages/trpc/routers/sharedLists.test.ts +++ b/packages/trpc/routers/sharedLists.test.ts @@ -2,11 +2,35 @@ import { beforeEach, describe, expect, test } from "vitest"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; -import type { CustomTestContext } from "../testUtils"; +import type { APICallerType, CustomTestContext } from "../testUtils"; import { defaultBeforeEach } from "../testUtils"; beforeEach<CustomTestContext>(defaultBeforeEach(true)); +/** + * Helper function to add a collaborator and have them accept the invitation + */ +async function addAndAcceptCollaborator( + ownerApi: APICallerType, + collaboratorApi: APICallerType, + listId: string, + role: "viewer" | "editor", +) { + const collaboratorUser = await collaboratorApi.users.whoami(); + + // Owner invites the collaborator + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId, + email: collaboratorUser.email!, + role, + }); + + // Collaborator accepts the invitation + await collaboratorApi.lists.acceptInvitation({ + invitationId, + }); +} + describe("Shared Lists", () => { describe("List Collaboration Management", () => { test<CustomTestContext>("should allow owner to add a collaborator by email", async ({ @@ -26,12 +50,13 @@ describe("Shared Lists", () => { const collaboratorUser = await collaboratorApi.users.whoami(); const collaboratorEmail = collaboratorUser.email!; - // Add collaborator - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + // Add collaborator (creates pending invitation) + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Verify collaborator was added const { collaborators, owner } = await ownerApi.lists.getCollaborators({ @@ -84,13 +109,27 @@ describe("Shared Lists", () => { const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ + const { invitationId } = await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); - // Try to add same collaborator again + // Try to add same collaborator again (should fail - pending invitation exists) + await expect( + ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "editor", + }), + ).rejects.toThrow("User already has a pending invitation for this list"); + + // Accept the invitation + await collaboratorApi.lists.acceptInvitation({ + invitationId, + }); + + // Try to add them again after they're a collaborator await expect( ownerApi.lists.addCollaborator({ listId: list.id, @@ -114,11 +153,12 @@ describe("Shared Lists", () => { const collaboratorUser = await collaboratorApi.users.whoami(); - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorUser.email!, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Update role to editor await ownerApi.lists.updateCollaboratorRole({ @@ -150,11 +190,12 @@ describe("Shared Lists", () => { const collaboratorUser = await collaboratorApi.users.whoami(); - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorUser.email!, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Remove collaborator await ownerApi.lists.removeCollaborator({ @@ -223,11 +264,12 @@ describe("Shared Lists", () => { const collaboratorUser = await collaboratorApi.users.whoami(); - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorUser.email!, - role: "editor", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "editor", + ); // Collaborator adds their own bookmark const collabBookmark = await collaboratorApi.bookmarks.createBookmark({ @@ -279,13 +321,12 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Collaborator leaves the list await collaboratorApi.lists.leaveList({ @@ -331,13 +372,12 @@ describe("Shared Lists", () => { bookmarkId: ownerBookmark.id, }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "editor", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "editor", + ); // Collaborator adds their own bookmark const collabBookmark = await collaboratorApi.bookmarks.createBookmark({ @@ -434,13 +474,12 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); const { lists: allLists } = await collaboratorApi.lists.list(); const sharedLists = allLists.filter( @@ -464,13 +503,12 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); const retrievedList = await collaboratorApi.lists.get({ listId: list.id, @@ -494,13 +532,12 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); await expect( thirdUserApi.lists.get({ @@ -539,13 +576,12 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "editor", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "editor", + ); const retrievedList = await collaboratorApi.lists.get({ listId: list.id, @@ -579,13 +615,12 @@ describe("Shared Lists", () => { bookmarkId: bookmark.id, }); - // Share list with collaborator - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Collaborator fetches bookmarks from shared list const bookmarks = await collaboratorApi.bookmarks.getBookmarks({ @@ -625,12 +660,12 @@ describe("Shared Lists", () => { bookmarkId: bookmark.id, }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); const ownerView = await ownerApi.bookmarks.getBookmarks({ listId: list.id, @@ -680,12 +715,12 @@ describe("Shared Lists", () => { bookmarkId: bookmark.id, }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Collaborator gets individual bookmark const response = await collaboratorApi.bookmarks.getBookmark({ @@ -717,12 +752,12 @@ describe("Shared Lists", () => { bookmarkId: sharedBookmark.id, }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Collaborator creates their own bookmark const ownBookmark = await collaboratorApi.bookmarks.createBookmark({ @@ -761,12 +796,12 @@ describe("Shared Lists", () => { bookmarkId: bookmark.id, }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Don't add thirdUserApi as a collaborator // Third user tries to access the bookmark @@ -801,12 +836,12 @@ describe("Shared Lists", () => { }); // Share list with collaborator as editor - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "editor", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "editor", + ); // Collaborator adds their own bookmark const collabBookmark = await collaboratorApi.bookmarks.createBookmark({ @@ -846,12 +881,12 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Viewer creates their own bookmark const bookmark = await collaboratorApi.bookmarks.createBookmark({ @@ -880,12 +915,12 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "editor", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "editor", + ); // Editor creates their own bookmark const bookmark = await collaboratorApi.bookmarks.createBookmark({ @@ -930,12 +965,12 @@ describe("Shared Lists", () => { bookmarkId: bookmark.id, }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Viewer tries to remove bookmark await expect( @@ -968,12 +1003,12 @@ describe("Shared Lists", () => { bookmarkId: bookmark.id, }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "editor", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "editor", + ); // Editor removes bookmark await collaboratorApi.lists.removeFromList({ @@ -1011,12 +1046,12 @@ describe("Shared Lists", () => { bookmarkId: bookmark.id, }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "editor", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "editor", + ); // Collaborator tries to edit owner's bookmark await expect( @@ -1049,12 +1084,12 @@ describe("Shared Lists", () => { bookmarkId: bookmark.id, }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "editor", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "editor", + ); // Collaborator tries to delete owner's bookmark await expect( @@ -1078,12 +1113,12 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "editor", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "editor", + ); // Collaborator tries to edit list await expect( @@ -1106,12 +1141,12 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "editor", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "editor", + ); // Collaborator tries to delete list await expect( @@ -1134,12 +1169,12 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "editor", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "editor", + ); const thirdUserEmail = (await thirdUserApi.users.whoami()).email!; @@ -1166,12 +1201,12 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Collaborator can view collaborators const { collaborators, owner } = @@ -1215,11 +1250,13 @@ describe("Shared Lists", () => { }); const collaboratorUser = await collaboratorApi.users.whoami(); - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorUser.email!, - role: "viewer", - }); + + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Verify collaborator has access to list const bookmarksBefore = await collaboratorApi.bookmarks.getBookmarks({ @@ -1266,12 +1303,12 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Collaborator leaves await collaboratorApi.lists.leaveList({ @@ -1332,17 +1369,18 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list1.id, - email: collaboratorEmail, - role: "editor", - }); - await ownerApi.lists.addCollaborator({ - listId: list2.id, - email: collaboratorEmail, - role: "editor", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list1.id, + "editor", + ); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list2.id, + "editor", + ); // Collaborator tries to merge the shared list into another list await expect( @@ -1366,12 +1404,12 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "editor", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "editor", + ); // Collaborator tries to generate RSS token await expect( @@ -1422,12 +1460,12 @@ describe("Shared Lists", () => { bookmarkId: bookmark.id, }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Collaborator cannot use getListsOfBookmark for owner's bookmark // This is expected - only bookmark owners can query which lists contain their bookmarks @@ -1435,7 +1473,7 @@ describe("Shared Lists", () => { collaboratorApi.lists.getListsOfBookmark({ bookmarkId: bookmark.id, }), - ).rejects.toThrow(); + ).rejects.toThrow("User is not allowed to access resource"); }); test<CustomTestContext>("should allow collaborator to use getListsOfBookmark for their own bookmarks in shared lists", async ({ @@ -1450,12 +1488,12 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "editor", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "editor", + ); // Collaborator creates their own bookmark and adds it to the shared list const bookmark = await collaboratorApi.bookmarks.createBookmark({ @@ -1504,12 +1542,12 @@ describe("Shared Lists", () => { }); // Add a collaborator - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Owner queries which lists contain their bookmark const { lists } = await ownerApi.lists.getListsOfBookmark({ @@ -1587,12 +1625,12 @@ describe("Shared Lists", () => { bookmarkId: bookmark2.id, }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Collaborator gets stats const { stats } = await collaboratorApi.lists.stats(); @@ -1605,7 +1643,7 @@ describe("Shared Lists", () => { apiCallers, }) => { const ownerApi = apiCallers[0]; - const editorApi = apiCallers[1]; + const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", @@ -1613,21 +1651,21 @@ describe("Shared Lists", () => { type: "manual", }); - const editorEmail = (await editorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: editorEmail, - role: "editor", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "editor", + ); // Editor creates their own bookmark - const bookmark = await editorApi.bookmarks.createBookmark({ + const bookmark = await collaboratorApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Editor's bookmark", }); // Editor should be able to add their bookmark to the shared list - await editorApi.lists.addToList({ + await collaboratorApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }); @@ -1653,12 +1691,7 @@ describe("Shared Lists", () => { type: "manual", }); - const viewerEmail = (await viewerApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: viewerEmail, - role: "viewer", - }); + await addAndAcceptCollaborator(ownerApi, viewerApi, list.id, "viewer"); // Viewer creates their own bookmark const bookmark = await viewerApi.bookmarks.createBookmark({ @@ -1688,12 +1721,7 @@ describe("Shared Lists", () => { type: "manual", }); - const editorEmail = (await editorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: editorEmail, - role: "editor", - }); + await addAndAcceptCollaborator(ownerApi, editorApi, list.id, "editor"); // Third user creates a bookmark (or owner if only 2 users) const bookmark = await thirdUserApi.bookmarks.createBookmark({ @@ -1722,12 +1750,7 @@ describe("Shared Lists", () => { type: "manual", }); - const editorEmail = (await editorApi.users.whoami()).email!; - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: editorEmail, - role: "editor", - }); + await addAndAcceptCollaborator(ownerApi, editorApi, list.id, "editor"); // Editor tries to change list name await expect( @@ -1774,13 +1797,12 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Fetch the list again to get updated hasCollaborators const updatedList = await ownerApi.lists.get({ @@ -1802,13 +1824,12 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Collaborator fetches the list const sharedList = await collaboratorApi.lists.get({ @@ -1832,11 +1853,12 @@ describe("Shared Lists", () => { const collaboratorUser = await collaboratorApi.users.whoami(); - await ownerApi.lists.addCollaborator({ - listId: list.id, - email: collaboratorUser.email!, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); // Remove the collaborator await ownerApi.lists.removeCollaborator({ @@ -1872,13 +1894,12 @@ describe("Shared Lists", () => { type: "manual", }); - const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; - - await ownerApi.lists.addCollaborator({ - listId: list2.id, - email: collaboratorEmail, - role: "viewer", - }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list2.id, + "viewer", + ); // Get all lists const { lists } = await ownerApi.lists.list(); @@ -1902,6 +1923,233 @@ describe("Shared Lists", () => { type: "manual", }); + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "editor", + ); + + // Collaborator gets all lists + const { lists } = await collaboratorApi.lists.list(); + + const sharedList = lists.find((l) => l.id === list.id); + + expect(sharedList?.hasCollaborators).toBe(true); + expect(sharedList?.userRole).toBe("editor"); + }); + }); + + describe("List Invitations", () => { + test<CustomTestContext>("should create pending invitation when adding collaborator", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + // Add collaborator (creates pending invitation) + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Check that collaborator has a pending invitation + const pendingInvitations = + await collaboratorApi.lists.getPendingInvitations(); + + expect(pendingInvitations).toHaveLength(1); + expect(pendingInvitations[0].listId).toBe(list.id); + expect(pendingInvitations[0].role).toBe("viewer"); + }); + + test<CustomTestContext>("should allow collaborator to accept invitation", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Accept the invitation + await collaboratorApi.lists.acceptInvitation({ + invitationId, + }); + + // Verify collaborator was added + const { collaborators } = await ownerApi.lists.getCollaborators({ + listId: list.id, + }); + + expect(collaborators).toHaveLength(1); + expect(collaborators[0].user.email).toBe(collaboratorEmail); + expect(collaborators[0].status).toBe("accepted"); + + // Verify no more pending invitations + const pendingInvitations = + await collaboratorApi.lists.getPendingInvitations(); + + expect(pendingInvitations).toHaveLength(0); + }); + + test<CustomTestContext>("should allow collaborator to decline invitation", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Decline the invitation + await collaboratorApi.lists.declineInvitation({ + invitationId, + }); + + // Verify collaborator was not added + const { collaborators } = await ownerApi.lists.getCollaborators({ + listId: list.id, + }); + + expect(collaborators).toHaveLength(1); + expect(collaborators[0].status).toBe("declined"); + + // Verify no pending invitations + const pendingInvitations = + await collaboratorApi.lists.getPendingInvitations(); + + expect(pendingInvitations).toHaveLength(0); + }); + + test<CustomTestContext>("should allow owner to revoke pending invitation", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorUser = await collaboratorApi.users.whoami(); + + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorUser.email!, + role: "viewer", + }); + + // Owner revokes the invitation + await ownerApi.lists.revokeInvitation({ + invitationId, + }); + + // Verify invitation was revoked + const pendingInvitations = + await collaboratorApi.lists.getPendingInvitations(); + + expect(pendingInvitations).toHaveLength(0); + }); + + test<CustomTestContext>("should not allow access to list with pending invitation", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const bookmark = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Test bookmark", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + // Add collaborator but don't accept invitation + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Collaborator should not be able to access the list yet + await expect( + collaboratorApi.lists.get({ + listId: list.id, + }), + ).rejects.toThrow("List not found"); + + // Collaborator should not be able to access bookmarks in the list + await expect( + collaboratorApi.bookmarks.getBookmarks({ + listId: list.id, + }), + ).rejects.toThrow("List not found"); + + // Collaborator should not be able to access individual bookmarks + await expect( + collaboratorApi.bookmarks.getBookmark({ + bookmarkId: bookmark.id, + }), + ).rejects.toThrow("Bookmark not found"); + }); + + test<CustomTestContext>("should show pending invitations with list details", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test Shared List", + icon: "📚", + description: "A test list for sharing", + type: "manual", + }); + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; await ownerApi.lists.addCollaborator({ @@ -1910,13 +2158,678 @@ describe("Shared Lists", () => { role: "editor", }); - // Collaborator gets all lists - const { lists } = await collaboratorApi.lists.list(); + const pendingInvitations = + await collaboratorApi.lists.getPendingInvitations(); - const sharedList = lists.find((l) => l.id === list.id); + expect(pendingInvitations).toHaveLength(1); + const invitation = pendingInvitations[0]; - expect(sharedList?.hasCollaborators).toBe(true); - expect(sharedList?.userRole).toBe("editor"); + expect(invitation.listId).toBe(list.id); + expect(invitation.role).toBe("editor"); + expect(invitation.list.name).toBe("Test Shared List"); + expect(invitation.list.icon).toBe("📚"); + expect(invitation.list.description).toBe("A test list for sharing"); + expect(invitation.list.owner).toBeDefined(); + }); + + test<CustomTestContext>("should show pending invitations in getCollaborators for owner", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Owner should see pending invitation in collaborators list + const { collaborators } = await ownerApi.lists.getCollaborators({ + listId: list.id, + }); + + expect(collaborators).toHaveLength(1); + expect(collaborators[0].status).toBe("pending"); + expect(collaborators[0].role).toBe("viewer"); + expect(collaborators[0].user.email).toBe(collaboratorEmail); + }); + + test<CustomTestContext>("should update hasCollaborators after invitation is accepted", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + // hasCollaborators should be false initially + expect(list.hasCollaborators).toBe(false); + + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // hasCollaborators should be false after adding invitation (pending does not counts) + const listAfterInvite = await ownerApi.lists.get({ + listId: list.id, + }); + expect(listAfterInvite.hasCollaborators).toBe(false); + + // Accept the invitation + await collaboratorApi.lists.acceptInvitation({ + invitationId, + }); + + // hasCollaborators should still be true + const listAfterAccept = await ownerApi.lists.get({ + listId: list.id, + }); + expect(listAfterAccept.hasCollaborators).toBe(true); + }); + + test<CustomTestContext>("should update hasCollaborators after invitation is declined", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // hasCollaborators should be false with pending invitation + const listAfterInvite = await ownerApi.lists.get({ + listId: list.id, + }); + expect(listAfterInvite.hasCollaborators).toBe(false); + + // Decline the invitation + await collaboratorApi.lists.declineInvitation({ + invitationId, + }); + + // hasCollaborators should be false after declining + const listAfterDecline = await ownerApi.lists.get({ + listId: list.id, + }); + expect(listAfterDecline.hasCollaborators).toBe(false); + }); + + test<CustomTestContext>("should not show declined invitations in pending list", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Decline the invitation + await collaboratorApi.lists.declineInvitation({ + invitationId, + }); + + // Should not appear in pending invitations + const pendingInvitations = + await collaboratorApi.lists.getPendingInvitations(); + + expect(pendingInvitations).toHaveLength(0); + }); + + test<CustomTestContext>("should allow re-inviting after decline", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + // First invitation + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Decline it + await collaboratorApi.lists.declineInvitation({ + invitationId, + }); + + // Re-invite with different role + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "editor", + }); + + // Should have a new pending invitation + const pendingInvitations = + await collaboratorApi.lists.getPendingInvitations(); + + expect(pendingInvitations).toHaveLength(1); + expect(pendingInvitations[0].role).toBe("editor"); + }); + + test<CustomTestContext>("should not allow accepting non-existent invitation", async ({ + apiCallers, + }) => { + const collaboratorApi = apiCallers[1]; + + const fakeInvitationId = "non-existent-invitation-id"; + + await expect( + collaboratorApi.lists.acceptInvitation({ + invitationId: fakeInvitationId, + }), + ).rejects.toThrow("Invitation not found"); + }); + + test<CustomTestContext>("should not allow accepting already accepted invitation", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Accept once + await collaboratorApi.lists.acceptInvitation({ + invitationId, + }); + + // Try to accept again (should fail since invitation is already accepted and deleted) + await expect( + collaboratorApi.lists.acceptInvitation({ + invitationId, + }), + ).rejects.toThrow("Invitation not found"); + }); + + test<CustomTestContext>("should show list in shared lists only after accepting invitation", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // List should not appear in collaborator's lists yet + const listsBefore = await collaboratorApi.lists.list(); + expect(listsBefore.lists.find((l) => l.id === list.id)).toBeUndefined(); + + // Accept invitation + await collaboratorApi.lists.acceptInvitation({ + invitationId, + }); + + // Now list should appear + const listsAfter = await collaboratorApi.lists.list(); + const sharedList = listsAfter.lists.find((l) => l.id === list.id); + expect(sharedList).toBeDefined(); + expect(sharedList?.userRole).toBe("viewer"); + }); + + test<CustomTestContext>("should handle multiple pending invitations for different lists", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list1 = await ownerApi.lists.create({ + name: "List 1", + icon: "📚", + type: "manual", + }); + + const list2 = await ownerApi.lists.create({ + name: "List 2", + icon: "📖", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + // Invite to both lists + const { invitationId: invitationId1 } = + await ownerApi.lists.addCollaborator({ + listId: list1.id, + email: collaboratorEmail, + role: "viewer", + }); + + const { invitationId: invitationId2 } = + await ownerApi.lists.addCollaborator({ + listId: list2.id, + email: collaboratorEmail, + role: "editor", + }); + + // Should have 2 pending invitations + const pendingInvitations = + await collaboratorApi.lists.getPendingInvitations(); + + expect(pendingInvitations).toHaveLength(2); + + // Accept one + await collaboratorApi.lists.acceptInvitation({ + invitationId: invitationId1, + }); + + // Should have 1 pending invitation left + const remainingInvitations = + await collaboratorApi.lists.getPendingInvitations(); + + expect(remainingInvitations).toHaveLength(1); + expect(remainingInvitations[0].id).toBe(invitationId2); + expect(remainingInvitations[0].listId).toBe(list2.id); + }); + + test<CustomTestContext>("should not allow collaborator to revoke invitation", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + const thirdUserApi = apiCallers[2]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + // Owner adds collaborator 1 and they accept + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "editor", + ); + + // Owner invites third user + const thirdUserEmail = (await thirdUserApi.users.whoami()).email!; + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: thirdUserEmail, + role: "viewer", + }); + + // Collaborator tries to revoke the third user's invitation + // Collaborator cannot access the invitation at all (not the invitee, not the owner) + await expect( + collaboratorApi.lists.revokeInvitation({ + invitationId, + }), + ).rejects.toThrow("Invitation not found"); + }); + + test<CustomTestContext>("should not allow invited user to revoke their own invitation", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Invited user tries to revoke (should only be able to decline) + await expect( + collaboratorApi.lists.revokeInvitation({ + invitationId, + }), + ).rejects.toThrow("Only the list owner can perform this action"); + }); + + test<CustomTestContext>("should not allow non-owner/non-invitee to access invitation", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + const thirdUserApi = apiCallers[2]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Third user (not owner, not invitee) tries to revoke invitation + await expect( + thirdUserApi.lists.revokeInvitation({ + invitationId, + }), + ).rejects.toThrow("Invitation not found"); + + // Third user tries to accept invitation + await expect( + thirdUserApi.lists.acceptInvitation({ + invitationId, + }), + ).rejects.toThrow("Invitation not found"); + + // Third user tries to decline invitation + await expect( + thirdUserApi.lists.declineInvitation({ + invitationId, + }), + ).rejects.toThrow("Invitation not found"); + }); + + test<CustomTestContext>("should not show invitations to collaborators in getCollaborators", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + const thirdUserApi = apiCallers[2]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + // Owner adds collaborator 1 and they accept + await addAndAcceptCollaborator( + ownerApi, + collaboratorApi, + list.id, + "viewer", + ); + + // Owner invites third user (pending invitation) + const thirdUserEmail = (await thirdUserApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: thirdUserEmail, + role: "viewer", + }); + + // Owner should see 2 collaborators (1 accepted + 1 pending) + const ownerView = await ownerApi.lists.getCollaborators({ + listId: list.id, + }); + expect(ownerView.collaborators).toHaveLength(2); + + // Collaborator should only see 1 (themselves, no pending invitations) + const collaboratorView = await collaboratorApi.lists.getCollaborators({ + listId: list.id, + }); + expect(collaboratorView.collaborators).toHaveLength(1); + expect(collaboratorView.collaborators[0].status).toBe("accepted"); + }); + + test<CustomTestContext>("should allow owner to see both accepted collaborators and pending invitations", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + const thirdUserApi = apiCallers[2]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + // Add and accept one collaborator + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "editor", + }); + + await collaboratorApi.lists.acceptInvitation({ + invitationId, + }); + + // Add pending invitation for third user + const thirdUserEmail = (await thirdUserApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: thirdUserEmail, + role: "viewer", + }); + + // Owner should see both + const { collaborators } = await ownerApi.lists.getCollaborators({ + listId: list.id, + }); + + expect(collaborators).toHaveLength(2); + + const acceptedCollaborator = collaborators.find( + (c) => c.status === "accepted", + ); + const pendingCollaborator = collaborators.find( + (c) => c.status === "pending", + ); + + expect(acceptedCollaborator).toBeDefined(); + expect(acceptedCollaborator?.role).toBe("editor"); + expect(acceptedCollaborator?.user.email).toBe(collaboratorEmail); + + expect(pendingCollaborator).toBeDefined(); + expect(pendingCollaborator?.role).toBe("viewer"); + expect(pendingCollaborator?.user.email).toBe(thirdUserEmail); + }); + + test<CustomTestContext>("should not show invitee name for pending invitations", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorUser = await collaboratorApi.users.whoami(); + const collaboratorEmail = collaboratorUser.email!; + const collaboratorName = collaboratorUser.name; + + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Owner checks pending invitations + const { collaborators } = await ownerApi.lists.getCollaborators({ + listId: list.id, + }); + + const pendingInvitation = collaborators.find( + (c) => c.status === "pending", + ); + + expect(pendingInvitation).toBeDefined(); + // Name should be masked as "Pending User" + expect(pendingInvitation?.user.name).toBe("Pending User"); + // Name should NOT be the actual user's name + expect(pendingInvitation?.user.name).not.toBe(collaboratorName); + // Email should still be visible to owner + expect(pendingInvitation?.user.email).toBe(collaboratorEmail); + }); + + test<CustomTestContext>("should show invitee name after invitation is accepted", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorUser = await collaboratorApi.users.whoami(); + const collaboratorEmail = collaboratorUser.email!; + const collaboratorName = collaboratorUser.name; + + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Before acceptance - name should be masked + const beforeAccept = await ownerApi.lists.getCollaborators({ + listId: list.id, + }); + const pendingInvitation = beforeAccept.collaborators.find( + (c) => c.status === "pending", + ); + expect(pendingInvitation?.user.name).toBe("Pending User"); + + // Accept invitation + await collaboratorApi.lists.acceptInvitation({ + invitationId, + }); + + // After acceptance - name should be visible + const afterAccept = await ownerApi.lists.getCollaborators({ + listId: list.id, + }); + const acceptedCollaborator = afterAccept.collaborators.find( + (c) => c.status === "accepted", + ); + expect(acceptedCollaborator?.user.name).toBe(collaboratorName); + expect(acceptedCollaborator?.user.email).toBe(collaboratorEmail); + }); + + test<CustomTestContext>("should not show invitee name for declined invitations", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorUser = await collaboratorApi.users.whoami(); + const collaboratorEmail = collaboratorUser.email!; + const collaboratorName = collaboratorUser.name; + + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Decline the invitation + await collaboratorApi.lists.declineInvitation({ + invitationId, + }); + + // Owner checks declined invitations + const { collaborators } = await ownerApi.lists.getCollaborators({ + listId: list.id, + }); + + const declinedInvitation = collaborators.find( + (c) => c.status === "declined", + ); + + expect(declinedInvitation).toBeDefined(); + // Name should still be masked as "Pending User" even after decline + expect(declinedInvitation?.user.name).toBe("Pending User"); + expect(declinedInvitation?.user.name).not.toBe(collaboratorName); + // Email should still be visible to owner + expect(declinedInvitation?.user.email).toBe(collaboratorEmail); }); }); }); |
