Skip to content

Commit f07dc8d

Browse files
apataaerosol
andauthored
Display segment filters to anyone that can see the dashboard being filtered by the segment (#5935)
* Remove segment filters secrecy * Update changelog * Update CHANGELOG.md Co-authored-by: Adam Rutkowski <hq@mtod.org> --------- Co-authored-by: Adam Rutkowski <hq@mtod.org>
1 parent 3838119 commit f07dc8d

6 files changed

Lines changed: 181 additions & 96 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ All notable changes to this project will be documented in this file.
1010

1111
### Changed
1212

13+
- Segment filters are visible to anyone who can view the dashboard with that segment applied, including personal segments on public dashboards
14+
1315
### Fixed
1416

1517
- To make internal stats API requests for password-protected shared links, shared link auth cookie must be set in the requests

assets/js/dashboard/filtering/segments.test.ts

Lines changed: 114 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
SegmentType,
1111
SavedSegment,
1212
SegmentData,
13-
canSeeSegmentDetails
13+
canExpandSegment
1414
} from './segments'
1515
import { Filter } from '../query'
1616
import { PlausibleSite } from '../site-context'
@@ -183,34 +183,124 @@ describe(`${resolveFilters.name}`, () => {
183183
)
184184
})
185185

186-
describe(`${canSeeSegmentDetails.name}`, () => {
187-
it('should return true if the user is logged in and not a public role', () => {
188-
const user: UserContextValue = {
189-
loggedIn: true,
190-
role: Role.admin,
191-
id: 1,
192-
team: { identifier: null, hasConsolidatedView: false }
186+
describe(`${canExpandSegment.name}`, () => {
187+
it.each([[Role.admin], [Role.editor], [Role.owner]])(
188+
'allows expanding site segment if the user is logged in and in the role %p',
189+
(role) => {
190+
const site = { siteSegmentsAvailable: true }
191+
const user: UserContextValue = {
192+
loggedIn: true,
193+
role,
194+
id: 1,
195+
team: { identifier: null, hasConsolidatedView: false }
196+
}
197+
expect(
198+
canExpandSegment({
199+
segment: { id: 1, owner_id: 1, type: SegmentType.site },
200+
user,
201+
site
202+
})
203+
).toBe(true)
193204
}
194-
expect(canSeeSegmentDetails({ user })).toBe(true)
205+
)
206+
207+
it('allows expanding site segments defined by other users', () => {
208+
expect(
209+
canExpandSegment({
210+
segment: { id: 1, owner_id: 222, type: SegmentType.site },
211+
user: {
212+
loggedIn: true,
213+
role: Role.owner,
214+
id: 111,
215+
team: { identifier: null, hasConsolidatedView: false }
216+
},
217+
site: { siteSegmentsAvailable: true }
218+
})
219+
).toBe(true)
195220
})
196221

197-
it('should return false if the user is not logged in', () => {
198-
const user: UserContextValue = {
199-
loggedIn: false,
200-
role: Role.editor,
201-
id: null,
202-
team: { identifier: null, hasConsolidatedView: false }
203-
}
204-
expect(canSeeSegmentDetails({ user })).toBe(false)
222+
it('forbids expanding site segment if site segments are not available', () => {
223+
expect(
224+
canExpandSegment({
225+
segment: { id: 1, owner_id: 1, type: SegmentType.site },
226+
user: {
227+
loggedIn: true,
228+
role: Role.owner,
229+
id: 1,
230+
team: { identifier: null, hasConsolidatedView: false }
231+
},
232+
site: { siteSegmentsAvailable: false }
233+
})
234+
).toBe(false)
205235
})
206236

207-
it('should return false if the user has a public role', () => {
208-
const user: UserContextValue = {
209-
loggedIn: true,
210-
role: Role.public,
211-
id: 1,
212-
team: { identifier: null, hasConsolidatedView: false }
237+
it('forbids public role from expanding site segments', () => {
238+
expect(
239+
canExpandSegment({
240+
segment: { id: 1, owner_id: null, type: SegmentType.site },
241+
user: {
242+
loggedIn: false,
243+
role: Role.public,
244+
id: null,
245+
team: { identifier: null, hasConsolidatedView: false }
246+
},
247+
site: { siteSegmentsAvailable: false }
248+
})
249+
).toBe(false)
250+
})
251+
252+
it.each([
253+
[Role.viewer],
254+
[Role.billing],
255+
[Role.editor],
256+
[Role.admin],
257+
[Role.owner]
258+
])(
259+
'allows expanding personal segment if it belongs to the user and the user is in role %p',
260+
(role) => {
261+
const user: UserContextValue = {
262+
loggedIn: true,
263+
role,
264+
id: 1,
265+
team: { identifier: null, hasConsolidatedView: false }
266+
}
267+
expect(
268+
canExpandSegment({
269+
segment: { id: 1, owner_id: 1, type: SegmentType.personal },
270+
user,
271+
site: { siteSegmentsAvailable: false }
272+
})
273+
).toBe(true)
213274
}
214-
expect(canSeeSegmentDetails({ user })).toBe(false)
275+
)
276+
277+
it('forbids expanding personal segment of other users', () => {
278+
expect(
279+
canExpandSegment({
280+
segment: { id: 2, owner_id: 222, type: SegmentType.personal },
281+
user: {
282+
loggedIn: true,
283+
role: Role.owner,
284+
id: 111,
285+
team: { identifier: null, hasConsolidatedView: false }
286+
},
287+
site: { siteSegmentsAvailable: false }
288+
})
289+
).toBe(false)
290+
})
291+
292+
it('forbids public role from expanding personal segments', () => {
293+
expect(
294+
canExpandSegment({
295+
segment: { id: 1, owner_id: 1, type: SegmentType.personal },
296+
user: {
297+
loggedIn: false,
298+
role: Role.public,
299+
id: null,
300+
team: { identifier: null, hasConsolidatedView: false }
301+
},
302+
site: { siteSegmentsAvailable: false }
303+
})
304+
).toBe(false)
215305
})
216306
})

assets/js/dashboard/filtering/segments.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ export enum SegmentType {
1010
site = 'site'
1111
}
1212

13+
/** keep in sync with Plausible.Segments */
14+
const ROLES_WITH_MAYBE_SITE_SEGMENTS = [Role.admin, Role.editor, Role.owner]
15+
const ROLES_WITH_PERSONAL_SEGMENTS = [
16+
Role.billing,
17+
Role.viewer,
18+
Role.admin,
19+
Role.editor,
20+
Role.owner
21+
]
22+
1323
/** This type signifies that the owner can't be shown. */
1424
type SegmentOwnershipHidden = { owner_id: null; owner_name: null }
1525

@@ -148,6 +158,36 @@ export function resolveFilters(
148158
})
149159
}
150160

161+
export function canExpandSegment({
162+
segment,
163+
site,
164+
user
165+
}: {
166+
segment: Pick<SavedSegment, 'id' | 'owner_id' | 'type'>
167+
site: Pick<PlausibleSite, 'siteSegmentsAvailable'>
168+
user: UserContextValue
169+
}) {
170+
if (
171+
segment.type === SegmentType.site &&
172+
site.siteSegmentsAvailable &&
173+
user.loggedIn &&
174+
ROLES_WITH_MAYBE_SITE_SEGMENTS.includes(user.role)
175+
) {
176+
return true
177+
}
178+
179+
if (
180+
segment.type === SegmentType.personal &&
181+
user.loggedIn &&
182+
ROLES_WITH_PERSONAL_SEGMENTS.includes(user.role) &&
183+
user.id === segment.owner_id
184+
) {
185+
return true
186+
}
187+
188+
return false
189+
}
190+
151191
export function isListableSegment({
152192
segment,
153193
site,
@@ -173,10 +213,6 @@ export function isListableSegment({
173213
return false
174214
}
175215

176-
export function canSeeSegmentDetails({ user }: { user: UserContextValue }) {
177-
return user.loggedIn && user.role !== Role.public
178-
}
179-
180216
export function findAppliedSegmentFilter({ filters }: { filters: Filter[] }) {
181217
const segmentFilter = filters.find(isSegmentFilter)
182218
if (!segmentFilter) {

assets/js/dashboard/segments/segment-authorship.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import { SavedSegmentPublic, SavedSegment } from '../filtering/segments'
33
import { dateForSite, formatDayShort } from '../util/date'
44
import { useSiteContext } from '../site-context'
55

6-
type SegmentAuthorshipProps = { className?: string } & (
7-
| { showOnlyPublicData: true; segment: SavedSegmentPublic }
8-
| { showOnlyPublicData: false; segment: SavedSegment }
9-
)
6+
type SegmentAuthorshipProps = {
7+
className?: string
8+
showOnlyPublicData: boolean
9+
segment: SavedSegmentPublic | SavedSegment
10+
}
1011

1112
export function SegmentAuthorship({
1213
className,

assets/js/dashboard/segments/segment-modals.test.tsx

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -58,45 +58,6 @@ describe('Segment details modal - errors', () => {
5858
},
5959
message: `Segment not found with with ID "202020"`,
6060
siteOptions: { siteSegmentsAvailable: true }
61-
},
62-
{
63-
case: 'site segment is in list but not listable because site segments are not available',
64-
segments: [anyPersonalSegment, anySiteSegment],
65-
segmentId: anySiteSegment.id,
66-
user: {
67-
loggedIn: true,
68-
id: 1,
69-
role: Role.owner,
70-
team: { identifier: null, hasConsolidatedView: false }
71-
},
72-
message: `Segment not found with with ID "${anySiteSegment.id}"`,
73-
siteOptions: { siteSegmentsAvailable: false }
74-
},
75-
{
76-
case: 'personal segment is in list but not listable because it is a public dashboard',
77-
segments: [{ ...anyPersonalSegment, owner_id: null, owner_name: null }],
78-
segmentId: anyPersonalSegment.id,
79-
user: {
80-
loggedIn: false,
81-
id: null,
82-
role: Role.public,
83-
team: { identifier: null, hasConsolidatedView: false }
84-
},
85-
message: `Segment not found with with ID "${anyPersonalSegment.id}"`,
86-
siteOptions: { siteSegmentsAvailable: true }
87-
},
88-
{
89-
case: 'segment is in list and listable, but detailed view is not available because user is not logged in',
90-
segments: [{ ...anySiteSegment, owner_id: null, owner_name: null }],
91-
segmentId: anySiteSegment.id,
92-
user: {
93-
loggedIn: false,
94-
id: null,
95-
role: Role.public,
96-
team: { identifier: null, hasConsolidatedView: false }
97-
},
98-
message: 'Not enough permissions to see segment details',
99-
siteOptions: { siteSegmentsAvailable: true }
10061
}
10162
]
10263
it.each(cases)(

assets/js/dashboard/segments/segment-modals.tsx

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import React, { ReactNode, useCallback, useState } from 'react'
22
import ModalWithRouting from '../stats/modals/modal'
33
import {
4-
canSeeSegmentDetails,
5-
isListableSegment,
4+
canExpandSegment,
65
isSegmentFilter,
76
SavedSegment,
87
SEGMENT_TYPE_LABELS,
@@ -22,9 +21,9 @@ import { MutationStatus } from '@tanstack/react-query'
2221
import { ApiError } from '../api'
2322
import { ErrorPanel } from '../components/error-panel'
2423
import { useSegmentsContext } from '../filtering/segments-context'
25-
import { useSiteContext } from '../site-context'
2624
import { Role, UserContextValue, useUserContext } from '../user-context'
2725
import { removeFilterButtonClassname } from '../components/remove-filter-button'
26+
import { useSiteContext } from '../site-context'
2827

2928
interface ApiRequestProps {
3029
status: MutationStatus
@@ -501,20 +500,14 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => {
501500
const { query } = useQueryContext()
502501
const { segments } = useSegmentsContext()
503502

504-
const segment = segments
505-
.filter((s) => isListableSegment({ segment: s, site, user }))
506-
.find((s) => String(s.id) === String(id))
503+
const segment = segments.find((s) => String(s.id) === String(id))
507504

508505
let error: ApiError | null = null
509506

510507
if (!segment) {
511508
error = new ApiError(`Segment not found with with ID "${id}"`, {
512509
error: `Segment not found with with ID "${id}"`
513510
})
514-
} else if (!canSeeSegmentDetails({ user })) {
515-
error = new ApiError('Not enough permissions to see segment details', {
516-
error: `Not enough permissions to see segment details`
517-
})
518511
}
519512

520513
const data = !error ? segment : null
@@ -542,25 +535,27 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => {
542535

543536
<SegmentAuthorship
544537
segment={data}
545-
showOnlyPublicData={false}
538+
showOnlyPublicData={!user.loggedIn || user.role === Role.public}
546539
className="mt-4 text-sm"
547540
/>
548541
<div className="mt-4">
549542
<ButtonsRow>
550-
<AppNavigationLink
551-
className={primaryNeutralButtonClassName}
552-
path={rootRoute.path}
553-
search={(s) => ({
554-
...s,
555-
filters: data.segment_data.filters,
556-
labels: data.segment_data.labels
557-
})}
558-
state={{
559-
expandedSegment: data
560-
}}
561-
>
562-
Edit segment
563-
</AppNavigationLink>
543+
{canExpandSegment({ segment: data, site, user }) && (
544+
<AppNavigationLink
545+
className={primaryNeutralButtonClassName}
546+
path={rootRoute.path}
547+
search={(s) => ({
548+
...s,
549+
filters: data.segment_data.filters,
550+
labels: data.segment_data.labels
551+
})}
552+
state={{
553+
expandedSegment: data
554+
}}
555+
>
556+
Edit segment
557+
</AppNavigationLink>
558+
)}
564559

565560
<AppNavigationLink
566561
className={removeFilterButtonClassname}

0 commit comments

Comments
 (0)