Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/api-client/src/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion src/app/(main)/websites/WebsitesDataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
Expand Down
2 changes: 1 addition & 1 deletion src/app/(main)/websites/WebsitesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function WebsitesPage() {
{showActions && <WebsiteAddButton teamId={teamId} />}
</PageHeader>
<Panel>
<WebsitesDataTable teamId={teamId} showActions={showActions} />
<WebsitesDataTable teamId={teamId} includeTeams={!teamId} showActions={showActions} />
</Panel>
</Column>
</PageBody>
Expand Down
22 changes: 21 additions & 1 deletion src/app/(main)/websites/WebsitesTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 || {};
Expand Down Expand Up @@ -81,6 +97,10 @@ export function WebsitesTable({
{(row: any) => {
const websiteId = row.id;

if (!canUpdate(row)) {
return null;
}

return (
<LinkButton href={renderUrl(`/websites/${websiteId}/settings`)} variant="quiet">
<Icon>
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/websites/request-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
42 changes: 40 additions & 2 deletions src/queries/prisma/website.test.ts
Original file line number Diff line number Diff line change
@@ -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'),
}));
Expand Down Expand Up @@ -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();
Expand Down
19 changes: 17 additions & 2 deletions src/queries/prisma/website.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -129,18 +128,34 @@ 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,
},
},
},
},
],
},
include: {
team: {
select: {
members: {
where: {
userId,
},
select: {
userId: true,
role: true,
},
},
},
},
},
},
sanitizeSortFilters(filters, WEBSITE_SORT_FIELDS, { orderBy: 'name' }),
);
Expand Down
98 changes: 98 additions & 0 deletions tests/api/websites.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
});
});