From a815c3f10c48670d95a517553f176e139e6d1c4f Mon Sep 17 00:00:00 2001 From: Kieran Scott Date: Tue, 15 Sep 2026 21:55:53 +1000 Subject: [PATCH] Include team websites for all team roles in the Websites list includeTeams now matches canViewWebsite: websites the user owns, plus websites with no owner in any team the user belongs to, at any role. The Websites page passes it when no team is selected, and hides the edit action on rows the user cannot update. --- packages/api-client/src/generated/types.ts | 2 +- src/app/(main)/websites/WebsitesDataTable.tsx | 7 +- src/app/(main)/websites/WebsitesPage.tsx | 2 +- src/app/(main)/websites/WebsitesTable.tsx | 22 ++++- src/app/api/websites/request-schema.ts | 2 +- src/queries/prisma/website.test.ts | 42 +++++++- src/queries/prisma/website.ts | 19 +++- tests/api/websites.spec.ts | 98 +++++++++++++++++++ 8 files changed, 185 insertions(+), 9 deletions(-) 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); + }); +});