diff options
Diffstat (limited to 'packages/trpc')
| -rw-r--r-- | packages/trpc/email.ts | 63 | ||||
| -rw-r--r-- | packages/trpc/models/listInvitations.ts | 398 | ||||
| -rw-r--r-- | packages/trpc/models/lists.ts | 114 | ||||
| -rw-r--r-- | packages/trpc/routers/lists.ts | 92 | ||||
| -rw-r--r-- | packages/trpc/routers/sharedLists.test.ts | 1461 |
5 files changed, 1789 insertions, 339 deletions
diff --git a/packages/trpc/email.ts b/packages/trpc/email.ts index edf8ec92..3c0b8b39 100644 --- a/packages/trpc/email.ts +++ b/packages/trpc/email.ts @@ -179,3 +179,66 @@ If you didn't request a password reset, please ignore this email. Your password await transporter.sendMail(mailOptions); } + +export async function sendListInvitationEmail( + email: string, + inviterName: string, + listName: string, + listId: string, +) { + if (!serverConfig.email.smtp) { + // Silently fail if email is not configured + return; + } + + const transporter = createTransport({ + host: serverConfig.email.smtp.host, + port: serverConfig.email.smtp.port, + secure: serverConfig.email.smtp.secure, + auth: + serverConfig.email.smtp.user && serverConfig.email.smtp.password + ? { + user: serverConfig.email.smtp.user, + pass: serverConfig.email.smtp.password, + } + : undefined, + }); + + const inviteUrl = `${serverConfig.publicUrl}/dashboard/lists?pendingInvitation=${encodeURIComponent(listId)}`; + + const mailOptions = { + from: serverConfig.email.smtp.from, + to: email, + subject: `${inviterName} invited you to collaborate on "${listName}"`, + html: ` + <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> + <h2>You've been invited to collaborate on a list!</h2> + <p>${inviterName} has invited you to collaborate on the list <strong>"${listName}"</strong> in Karakeep.</p> + <p>Click the link below to view and accept or decline the invitation:</p> + <p> + <a href="${inviteUrl}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;"> + View Invitation + </a> + </p> + <p>If the button doesn't work, you can copy and paste this link into your browser:</p> + <p><a href="${inviteUrl}">${inviteUrl}</a></p> + <p>You can accept or decline this invitation from your Karakeep dashboard.</p> + <p>If you weren't expecting this invitation, you can safely ignore this email or decline it in your dashboard.</p> + </div> + `, + text: ` +You've been invited to collaborate on a list! + +${inviterName} has invited you to collaborate on the list "${listName}" in Karakeep. + +View your invitation by visiting this link: +${inviteUrl} + +You can accept or decline this invitation from your Karakeep dashboard. + +If you weren't expecting this invitation, you can safely ignore this email or decline it in your dashboard. + `, + }; + + await transporter.sendMail(mailOptions); +} diff --git a/packages/trpc/models/listInvitations.ts b/packages/trpc/models/listInvitations.ts new file mode 100644 index 00000000..6bdc8ffa --- /dev/null +++ b/packages/trpc/models/listInvitations.ts @@ -0,0 +1,398 @@ +import { TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; + +import { listCollaborators, listInvitations } from "@karakeep/db/schema"; + +import type { AuthedContext } from ".."; + +type Role = "viewer" | "editor"; +type InvitationStatus = "pending" | "declined"; + +interface InvitationData { + id: string; + listId: string; + userId: string; + role: Role; + status: InvitationStatus; + invitedAt: Date; + invitedEmail: string | null; + invitedBy: string | null; + listOwnerUserId: string; +} + +export class ListInvitation { + protected constructor( + protected ctx: AuthedContext, + protected invitation: InvitationData, + ) {} + + get id() { + return this.invitation.id; + } + + /** + * Load an invitation by ID + * Can be accessed by: + * - The invited user (userId matches) + * - The list owner (via list ownership check) + */ + static async fromId( + ctx: AuthedContext, + invitationId: string, + ): Promise<ListInvitation> { + const invitation = await ctx.db.query.listInvitations.findFirst({ + where: eq(listInvitations.id, invitationId), + with: { + list: { + columns: { + userId: true, + }, + }, + }, + }); + + if (!invitation) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Invitation not found", + }); + } + + // Check if user has access to this invitation + const isInvitedUser = invitation.userId === ctx.user.id; + const isListOwner = invitation.list.userId === ctx.user.id; + + if (!isInvitedUser && !isListOwner) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Invitation not found", + }); + } + + return new ListInvitation(ctx, { + id: invitation.id, + listId: invitation.listId, + userId: invitation.userId, + role: invitation.role, + status: invitation.status, + invitedAt: invitation.invitedAt, + invitedEmail: invitation.invitedEmail, + invitedBy: invitation.invitedBy, + listOwnerUserId: invitation.list.userId, + }); + } + + /** + * Ensure the current user is the invited user + */ + ensureIsInvitedUser() { + if (this.invitation.userId !== this.ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only the invited user can perform this action", + }); + } + } + + /** + * Ensure the current user is the list owner + */ + ensureIsListOwner() { + if (this.invitation.listOwnerUserId !== this.ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only the list owner can perform this action", + }); + } + } + + /** + * Accept the invitation + */ + async accept(): Promise<void> { + this.ensureIsInvitedUser(); + + if (this.invitation.status !== "pending") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Only pending invitations can be accepted", + }); + } + + await this.ctx.db.transaction(async (tx) => { + await tx + .delete(listInvitations) + .where(eq(listInvitations.id, this.invitation.id)); + + await tx + .insert(listCollaborators) + .values({ + listId: this.invitation.listId, + userId: this.invitation.userId, + role: this.invitation.role, + addedBy: this.invitation.invitedBy, + }) + .onConflictDoNothing(); + }); + } + + /** + * Decline the invitation + */ + async decline(): Promise<void> { + this.ensureIsInvitedUser(); + + if (this.invitation.status !== "pending") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Only pending invitations can be declined", + }); + } + + await this.ctx.db + .update(listInvitations) + .set({ + status: "declined", + }) + .where(eq(listInvitations.id, this.invitation.id)); + } + + /** + * Revoke the invitation (owner only) + */ + async revoke(): Promise<void> { + this.ensureIsListOwner(); + + await this.ctx.db + .delete(listInvitations) + .where(eq(listInvitations.id, this.invitation.id)); + } + + /** + * @returns the invitation ID + */ + static async inviteByEmail( + ctx: AuthedContext, + params: { + email: string; + role: Role; + listId: string; + listName: string; + listType: "manual" | "smart"; + listOwnerId: string; + inviterUserId: string; + inviterName: string | null; + }, + ): Promise<string> { + const { + email, + role, + listId, + listName, + listType, + listOwnerId, + inviterUserId, + inviterName, + } = params; + + const user = await ctx.db.query.users.findFirst({ + where: (users, { eq }) => eq(users.email, email), + }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "No user found with that email address", + }); + } + + if (user.id === listOwnerId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Cannot add the list owner as a collaborator", + }); + } + + if (listType !== "manual") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Only manual lists can have collaborators", + }); + } + + const existingCollaborator = await ctx.db.query.listCollaborators.findFirst( + { + where: and( + eq(listCollaborators.listId, listId), + eq(listCollaborators.userId, user.id), + ), + }, + ); + + if (existingCollaborator) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "User is already a collaborator on this list", + }); + } + + const existingInvitation = await ctx.db.query.listInvitations.findFirst({ + where: and( + eq(listInvitations.listId, listId), + eq(listInvitations.userId, user.id), + ), + }); + + if (existingInvitation) { + if (existingInvitation.status === "pending") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "User already has a pending invitation for this list", + }); + } else if (existingInvitation.status === "declined") { + await ctx.db + .update(listInvitations) + .set({ + status: "pending", + role, + invitedAt: new Date(), + invitedEmail: email, + invitedBy: inviterUserId, + }) + .where(eq(listInvitations.id, existingInvitation.id)); + + await this.sendInvitationEmail({ + email, + inviterName, + listName, + listId, + }); + return existingInvitation.id; + } + } + + const res = await ctx.db + .insert(listInvitations) + .values({ + listId, + userId: user.id, + role, + status: "pending", + invitedEmail: email, + invitedBy: inviterUserId, + }) + .returning(); + + await this.sendInvitationEmail({ + email, + inviterName, + listName, + listId, + }); + return res[0].id; + } + + static async pendingForUser(ctx: AuthedContext) { + const invitations = await ctx.db.query.listInvitations.findMany({ + where: and( + eq(listInvitations.userId, ctx.user.id), + eq(listInvitations.status, "pending"), + ), + with: { + list: { + columns: { + id: true, + name: true, + icon: true, + description: true, + rssToken: false, + }, + with: { + user: { + columns: { + id: true, + name: true, + email: true, + }, + }, + }, + }, + }, + }); + + return invitations.map((inv) => ({ + id: inv.id, + listId: inv.listId, + role: inv.role, + invitedAt: inv.invitedAt, + list: { + id: inv.list.id, + name: inv.list.name, + icon: inv.list.icon, + description: inv.list.description, + owner: inv.list.user + ? { + id: inv.list.user.id, + name: inv.list.user.name, + email: inv.list.user.email, + } + : null, + }, + })); + } + + static async invitationsForList( + ctx: AuthedContext, + params: { listId: string }, + ) { + const invitations = await ctx.db.query.listInvitations.findMany({ + where: eq(listInvitations.listId, params.listId), + with: { + user: { + columns: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + + return invitations.map((invitation) => ({ + id: invitation.id, + listId: invitation.listId, + userId: invitation.userId, + role: invitation.role, + status: invitation.status, + invitedAt: invitation.invitedAt, + addedAt: invitation.invitedAt, + user: { + id: invitation.user.id, + // Don't show the actual user's name for any invitation (pending or declined) + // This protects user privacy until they accept + name: "Pending User", + email: invitation.user.email || "", + }, + })); + } + + static async sendInvitationEmail(params: { + email: string; + inviterName: string | null; + listName: string; + listId: string; + }) { + try { + const { sendListInvitationEmail } = await import("../email"); + await sendListInvitationEmail( + params.email, + params.inviterName || "A user", + params.listName, + params.listId, + ); + } catch (error) { + // Log the error but don't fail the invitation + console.error("Failed to send list invitation email:", error); + } + } +} diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts index 2250819f..a0d9ca23 100644 --- a/packages/trpc/models/lists.ts +++ b/packages/trpc/models/lists.ts @@ -26,6 +26,7 @@ import { AuthedContext, Context } from ".."; import { buildImpersonatingAuthedContext } from "../lib/impersonate"; import { getBookmarkIdsFromMatcher } from "../lib/search"; import { Bookmark } from "./bookmarks"; +import { ListInvitation } from "./listInvitations"; interface ListCollaboratorEntry { membershipId: string; @@ -536,7 +537,7 @@ export abstract class List { throw new TRPCError({ code: "NOT_FOUND" }); } invariant(result[0].userId === this.ctx.user.id); - // Fetch current collaborators count to update hasCollaborators + // Fetch current collaborators to update hasCollaborators const collaboratorsCount = await this.ctx.db.query.listCollaborators.findMany({ where: eq(listCollaborators.listId, this.list.id), @@ -596,61 +597,24 @@ export abstract class List { /** * Add a collaborator to this list by email. + * Creates a pending invitation that must be accepted by the user. + * Returns the invitation ID. */ async addCollaboratorByEmail( email: string, role: "viewer" | "editor", - ): Promise<void> { + ): Promise<string> { this.ensureCanManage(); - // Look up the user by email - const user = await this.ctx.db.query.users.findFirst({ - where: (users, { eq }) => eq(users.email, email), - }); - - if (!user) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "No user found with that email address", - }); - } - - // Check that the user is not adding themselves - if (user.id === this.list.userId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Cannot add the list owner as a collaborator", - }); - } - - // Check that the collaborator is not already added - const existing = await this.ctx.db.query.listCollaborators.findFirst({ - where: and( - eq(listCollaborators.listId, this.list.id), - eq(listCollaborators.userId, user.id), - ), - }); - - if (existing) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "User is already a collaborator on this list", - }); - } - - // Only manual lists can be collaborative - if (this.list.type !== "manual") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Only manual lists can have collaborators", - }); - } - - await this.ctx.db.insert(listCollaborators).values({ - listId: this.list.id, - userId: user.id, + return await ListInvitation.inviteByEmail(this.ctx, { + email, role, - addedBy: this.ctx.user.id, + listId: this.list.id, + listName: this.list.name, + listType: this.list.type, + listOwnerId: this.list.userId, + inviterUserId: this.ctx.user.id, + inviterName: this.ctx.user.name ?? null, }); } @@ -738,23 +702,34 @@ export abstract class List { } /** - * Get all collaborators for this list. + * Get all collaborators for this list, including pending invitations. + * For privacy, pending invitations show masked user info unless the invitation has been accepted. */ async getCollaborators() { this.ensureCanView(); - const collaborators = await this.ctx.db.query.listCollaborators.findMany({ - where: eq(listCollaborators.listId, this.list.id), - with: { - user: { - columns: { - id: true, - name: true, - email: true, + const isOwner = this.list.userId === this.ctx.user.id; + + const [collaborators, invitations] = await Promise.all([ + this.ctx.db.query.listCollaborators.findMany({ + where: eq(listCollaborators.listId, this.list.id), + with: { + user: { + columns: { + id: true, + name: true, + email: true, + }, }, }, - }, - }); + }), + // Only show invitations for the owner + isOwner + ? ListInvitation.invitationsForList(this.ctx, { + listId: this.list.id, + }) + : [], + ]); // Get the owner information const owner = await this.ctx.db.query.users.findFirst({ @@ -766,14 +741,24 @@ export abstract class List { }, }); - return { - collaborators: collaborators.map((c) => ({ + const collaboratorEntries = collaborators.map((c) => { + return { id: c.id, userId: c.userId, role: c.role, + status: "accepted" as const, addedAt: c.addedAt, - user: c.user, - })), + invitedAt: c.addedAt, + user: { + id: c.user.id, + name: c.user.name, + email: c.user.email, + }, + }; + }); + + return { + collaborators: [...collaboratorEntries, ...invitations], owner: owner ? { id: owner.id, @@ -786,6 +771,7 @@ export abstract class List { /** * Get all lists shared with the user (as a collaborator). + * Only includes lists where the invitation has been accepted. */ static async getSharedWithUser( ctx: AuthedContext, 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); }); }); }); |
