diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index 0eedc67257..80576c6bea 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -12021,7 +12021,7 @@ export interface operations { listWebsites: { parameters: { query?: { - /** @description When present, include websites accessible through owned or managed teams. */ + /** @description When present, include websites accessible through team membership. */ includeTeams?: string; /** @description Maximum number of results to include. */ maxResults?: number; diff --git a/src/app/(main)/websites/WebsitesDataTable.tsx b/src/app/(main)/websites/WebsitesDataTable.tsx index 14089ee99c..54c56fcc31 100644 --- a/src/app/(main)/websites/WebsitesDataTable.tsx +++ b/src/app/(main)/websites/WebsitesDataTable.tsx @@ -8,14 +8,19 @@ import { WebsitesTable } from './WebsitesTable'; export function WebsitesDataTable({ userId, teamId, + includeTeams, showActions = true, }: { userId?: string; teamId?: string; + includeTeams?: boolean; showActions?: boolean; }) { const { user } = useLoginQuery(); - const queryResult = useUserWebsitesQuery({ userId: userId || user?.id, teamId }); + const queryResult = useUserWebsitesQuery( + { userId: userId || user?.id, teamId }, + includeTeams ? { includeTeams } : undefined, + ); const { renderUrl } = useNavigation(); const renderLink = (row: any) => ( diff --git a/src/app/(main)/websites/WebsitesPage.tsx b/src/app/(main)/websites/WebsitesPage.tsx index 4decd8f196..b3944945af 100644 --- a/src/app/(main)/websites/WebsitesPage.tsx +++ b/src/app/(main)/websites/WebsitesPage.tsx @@ -27,7 +27,7 @@ export function WebsitesPage() { {showActions && } - + diff --git a/src/app/(main)/websites/WebsitesTable.tsx b/src/app/(main)/websites/WebsitesTable.tsx index 825d02ff3e..1260243c5e 100644 --- a/src/app/(main)/websites/WebsitesTable.tsx +++ b/src/app/(main)/websites/WebsitesTable.tsx @@ -3,8 +3,14 @@ import { type ReactNode, useMemo } from 'react'; import { DateDistance } from '@/components/common/DateDistance'; import { LinkButton } from '@/components/common/LinkButton'; import { SortableLabel } from '@/components/common/SortableLabel'; -import { useMessages, useNavigation, useWebsiteListChartsQuery } from '@/components/hooks'; +import { + useLoginQuery, + useMessages, + useNavigation, + useWebsiteListChartsQuery, +} from '@/components/hooks'; import { SquarePen } from '@/components/icons'; +import { PERMISSIONS, ROLE_PERMISSIONS } from '@/lib/constants'; import { decodePunycodeDomain } from '@/lib/format'; import { WebsiteSparkline } from './WebsiteSparkline'; @@ -21,6 +27,16 @@ export function WebsitesTable({ }: WebsitesTableProps & { data?: any[] }) { const { t, labels } = useMessages(); const { renderUrl } = useNavigation(); + const { user } = useLoginQuery(); + + // Rows listed through team access carry the caller's membership; hide edit where it can't update. + const canUpdate = (row: any) => + row.userId === user?.id || + !row.team || + row.team.members?.some( + ({ userId, role }: { userId: string; role: string }) => + userId === user?.id && ROLE_PERMISSIONS[role]?.includes(PERMISSIONS.websiteUpdate), + ); const websiteIds = useMemo(() => data.map(row => row.id), [data]); const chartsQuery = useWebsiteListChartsQuery(websiteIds); const charts = chartsQuery.data?.data || {}; @@ -81,6 +97,10 @@ export function WebsitesTable({ {(row: any) => { const websiteId = row.id; + if (!canUpdate(row)) { + return null; + } + return ( diff --git a/src/app/api/websites/request-schema.ts b/src/app/api/websites/request-schema.ts index 63f7581e47..52448b5e4d 100644 --- a/src/app/api/websites/request-schema.ts +++ b/src/app/api/websites/request-schema.ts @@ -20,7 +20,7 @@ export const listWebsitesQuerySchema = z ...searchParams, ...sortingParams, includeTeams: z.string().optional().meta({ - description: 'When present, include websites accessible through owned or managed teams.', + description: 'When present, include websites accessible through team membership.', }), }) .meta({ id: 'ListWebsitesQuery' }); diff --git a/src/queries/prisma/website.test.ts b/src/queries/prisma/website.test.ts index 345b82beac..602f184f09 100644 --- a/src/queries/prisma/website.test.ts +++ b/src/queries/prisma/website.test.ts @@ -1,15 +1,18 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { deleteWebsite, resetWebsite } from './website'; +import { deleteWebsite, getAllUserWebsitesIncludingTeamAccess, resetWebsite } from './website'; -const { transactionMock, redisDelMock, redisSetMock } = vi.hoisted(() => ({ +const { transactionMock, redisDelMock, redisSetMock, pagedQueryMock } = vi.hoisted(() => ({ transactionMock: vi.fn(), redisDelMock: vi.fn(), redisSetMock: vi.fn(), + pagedQueryMock: vi.fn(), })); vi.mock('@/lib/prisma', () => ({ default: { transaction: transactionMock, + pagedQuery: pagedQueryMock, + getSearchParameters: () => ({}), }, getSchema: () => new URL(process.env.DATABASE_URL || '').searchParams.get('schema'), })); @@ -106,6 +109,41 @@ function createDeleteTx(calls: string[]) { }; } +describe('getAllUserWebsitesIncludingTeamAccess', () => { + beforeEach(() => { + pagedQueryMock.mockReset(); + pagedQueryMock.mockResolvedValue({ data: [], count: 0, page: 1, pageSize: 20 }); + }); + + test('lists owned websites and unowned websites of any team the user belongs to', async () => { + await getAllUserWebsitesIncludingTeamAccess('user-1'); + + expect(pagedQueryMock).toHaveBeenCalledWith( + 'website', + expect.objectContaining({ + where: { + OR: [ + { userId: 'user-1' }, + { + userId: null, + team: { deletedAt: null, members: { some: { userId: 'user-1' } } }, + }, + ], + deletedAt: null, + }, + include: { + team: { + select: { + members: { where: { userId: 'user-1' }, select: { userId: true, role: true } }, + }, + }, + }, + }), + expect.anything(), + ); + }); +}); + describe('website delete dependencies', () => { beforeEach(() => { transactionMock.mockReset(); diff --git a/src/queries/prisma/website.ts b/src/queries/prisma/website.ts index 707ff16834..4f296fe295 100644 --- a/src/queries/prisma/website.ts +++ b/src/queries/prisma/website.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; import type { Prisma, Website } from '@/generated/prisma/client'; -import { ROLES } from '@/lib/constants'; import prisma, { getSchema } from '@/lib/prisma'; import redis from '@/lib/redis'; import { sanitizeSortFilters } from '@/lib/sort'; @@ -129,11 +128,12 @@ export async function getAllUserWebsitesIncludingTeamAccess( OR: [ { userId }, { + // Matches canViewWebsite: a website with a userId is visible to its owner only. + userId: null, team: { deletedAt: null, members: { some: { - role: { in: [ROLES.teamOwner, ROLES.teamManager] }, userId, }, }, @@ -141,6 +141,21 @@ export async function getAllUserWebsitesIncludingTeamAccess( }, ], }, + include: { + team: { + select: { + members: { + where: { + userId, + }, + select: { + userId: true, + role: true, + }, + }, + }, + }, + }, }, sanitizeSortFilters(filters, WEBSITE_SORT_FIELDS, { orderBy: 'name' }), ); diff --git a/tests/api/websites.spec.ts b/tests/api/websites.spec.ts index 921c610a84..61199bc0ae 100644 --- a/tests/api/websites.spec.ts +++ b/tests/api/websites.spec.ts @@ -1,10 +1,15 @@ import { randomUUID } from 'node:crypto'; import { expect, test } from './fixtures'; +import { login } from './helpers/auth'; import { UNKNOWN_UUID } from './helpers/constants'; import { dateRange } from './helpers/dates'; import { + assertStatus, createTeam, + createUser, + createWebsite, deleteTeam, + deleteUser, deleteWebsite, uniqueDomain, uniqueName, @@ -291,3 +296,96 @@ test.describe('Websites', () => { websiteId = ''; }); }); + +test.describe('Websites listed through team access', () => { + test.describe.configure({ mode: 'serial' }); + + const teamIds: string[] = []; + const websiteIds: string[] = []; + let memberId = ''; + let memberToken = ''; + let teamWebsiteId = ''; + let otherTeamWebsiteId = ''; + + test.beforeAll(async ({ admin, api }) => { + const created = await createUser(admin, { role: 'view-only' }); + memberId = created.id; + memberToken = await login(api, created); + + const team = await createTeam(admin); + const otherTeam = await createTeam(admin); + teamIds.push(team.id, otherTeam.id); + + assertStatus( + await admin.post(`/api/teams/${team.id}/users`, { userId: memberId, role: 'team-view-only' }), + 200, + 'add team member', + ); + + teamWebsiteId = (await createWebsite(admin, { teamId: team.id })).id; + otherTeamWebsiteId = (await createWebsite(admin, { teamId: otherTeam.id })).id; + websiteIds.push(teamWebsiteId, otherTeamWebsiteId); + }); + + test.afterAll(async ({ admin }) => { + for (const id of websiteIds) { + await deleteWebsite(admin, id); + } + + for (const id of teamIds) { + await deleteTeam(admin, id); + } + + if (memberId) { + await deleteUser(admin, memberId); + } + }); + + for (const path of ['/api/websites', '/api/me/websites', '/api/users/{userId}/websites']) { + test(`GET ${path}?includeTeams lists team websites for a team-view-only member`, async ({ + api, + seed, + }) => { + const member = api.bearer(memberToken); + const url = path.replace('{userId}', memberId); + const withTeams = await member.get(url, { params: { includeTeams: 'true' } }); + const withoutTeams = await member.get(url); + const ids = withTeams.body.data.map((w: any) => w.id); + + expect(withTeams.status).toBe(200); + expect(ids).toContain(teamWebsiteId); + expect(ids).not.toContain(otherTeamWebsiteId); + expect(ids).not.toContain(seed.website.id); + expect(ids).not.toContain(seed.website2.id); + expect(withTeams.body.data.find((w: any) => w.id === teamWebsiteId).team.members).toEqual([ + { userId: memberId, role: 'team-view-only' }, + ]); + expect(withoutTeams.status).toBe(200); + expect(withoutTeams.body.data.map((w: any) => w.id)).not.toContain(teamWebsiteId); + }); + } + + test('the listed websites match what the member can view', async ({ api }) => { + const member = api.bearer(memberToken); + const listed = await member.get(`/api/websites/${teamWebsiteId}`); + const unlisted = await member.get(`/api/websites/${otherTeamWebsiteId}`); + + expect(listed.status).toBe(200); + expect(unlisted.status).toBe(401); + }); + + test('websites leave the list when the member leaves the team', async ({ admin, api }) => { + const member = api.bearer(memberToken); + assertStatus( + await admin.del(`/api/teams/${teamIds[0]}/users/${memberId}`), + 200, + 'remove team member', + ); + + const listed = await member.get('/api/websites', { params: { includeTeams: 'true' } }); + const view = await member.get(`/api/websites/${teamWebsiteId}`); + + expect(listed.body.data.map((w: any) => w.id)).not.toContain(teamWebsiteId); + expect(view.status).toBe(401); + }); +});