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);
+ });
+});