diff --git a/src/app/(main)/boards/BoardEditForm.tsx b/src/app/(main)/boards/BoardEditForm.tsx
index ec405ff35..0f5ded174 100644
--- a/src/app/(main)/boards/BoardEditForm.tsx
+++ b/src/app/(main)/boards/BoardEditForm.tsx
@@ -8,6 +8,7 @@ import {
Loading,
Row,
Select,
+ Text,
TextField,
} from '@umami/react-zen';
import { useBoardQuery, useMessages, useNavigation, useUpdateQuery } from '@/components/hooks';
@@ -97,6 +98,14 @@ export function BoardEditForm({
: type === BOARD_TYPES.link
? t(labels.link)
: t(labels.website);
+ const boardTypeLabel =
+ type === BOARD_TYPES.mixed
+ ? t(labels.open)
+ : type === BOARD_TYPES.pixel
+ ? t(labels.pixel)
+ : type === BOARD_TYPES.link
+ ? t(labels.link)
+ : t(labels.website);
const handleTypeChange = (value: string) => {
setValue('type', value as BoardType, { shouldDirty: true });
@@ -126,12 +135,16 @@ export function BoardEditForm({
rules={{ required: t(labels.required) }}
>
-
+ {boardId ? (
+ {boardTypeLabel}
+ ) : (
+
+ )}
{requiresBoardEntity(type) && (
diff --git a/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx b/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx
index 0e01eee5a..51cd24fb6 100644
--- a/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx
+++ b/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx
@@ -9,7 +9,7 @@ import {
TextField,
} from '@umami/react-zen';
import { useEffect, useMemo, useState } from 'react';
-import { useMessages } from '@/components/hooks';
+import { useApi, useMessages } from '@/components/hooks';
import { LinkSelect } from '@/components/input/LinkSelect';
import { PixelSelect } from '@/components/input/PixelSelect';
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
@@ -29,6 +29,16 @@ import {
} from '../boardComponentRegistry';
import { BoardComponentRenderer } from './BoardComponentRenderer';
+const COMPONENT_GROUP_ORDER: string[] = [
+ 'Traffic',
+ 'Events',
+ 'Behavior',
+ 'Realtime',
+ 'Growth',
+ 'Revenue',
+ 'Content',
+];
+
export function BoardComponentSelect({
teamId,
boardType,
@@ -47,6 +57,7 @@ export function BoardComponentSelect({
onClose: () => void;
}) {
const { t, labels, messages } = useMessages();
+ const { get, useQuery } = useApi();
const initialEntity = getComponentEntity(initialConfig);
const [selectedDef, setSelectedDef] = useState(null);
const [configValues, setConfigValues] = useState>({});
@@ -57,16 +68,17 @@ export function BoardComponentSelect({
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
- const allDefinitions = useMemo(
- () => getComponentDefinitions().toSorted((a, b) => a.name.localeCompare(b.name)),
- [],
- );
+ const allDefinitions = useMemo(() => getComponentDefinitions(), []);
const activeEntityType = isOpenBoardType(boardType) ? selectedEntityType : boardEntityType;
const isSelectedDefSupported = selectedDef
? isBoardComponentSupported(selectedDef.type, activeEntityType)
: false;
- const getDefaultConfigValues = (def: ComponentDefinition, config?: BoardComponentConfig) => {
+ const getDefaultConfigValues = (
+ def: ComponentDefinition,
+ config?: BoardComponentConfig,
+ entityType?: BoardEntityType,
+ ) => {
const defaults: Record = {};
for (const field of def.configFields ?? []) {
@@ -77,6 +89,19 @@ export function BoardComponentSelect({
Object.assign(defaults, def.defaultProps);
}
+ for (const field of def.configFields ?? []) {
+ if (field.type !== 'select') {
+ continue;
+ }
+
+ const options = (entityType && field.optionsByEntityType?.[entityType]) ?? field.options;
+ const currentValue = defaults[field.name];
+
+ if (options?.length && !options.some(option => String(option.value) === String(currentValue))) {
+ defaults[field.name] = options[0].value;
+ }
+ }
+
if (config?.props) {
Object.assign(defaults, config.props);
}
@@ -96,10 +121,9 @@ export function BoardComponentSelect({
}
setSelectedDef(definition);
- setConfigValues(getDefaultConfigValues(definition, initialConfig));
- setSelectedEntityType(
- initialEntity.entityType || boardEntityType || BOARD_ENTITY_TYPES.website,
- );
+ const entityType = initialEntity.entityType || boardEntityType || BOARD_ENTITY_TYPES.website;
+ setConfigValues(getDefaultConfigValues(definition, initialConfig, entityType));
+ setSelectedEntityType(entityType);
setSelectedEntityId(initialEntity.entityId || boardEntityId);
setTitle(initialConfig.title ?? definition.name);
setDescription(initialConfig.description || '');
@@ -114,7 +138,7 @@ export function BoardComponentSelect({
const handleSelectComponent = (def: ComponentDefinition) => {
setSelectedDef(def);
- setConfigValues(getDefaultConfigValues(def));
+ setConfigValues(getDefaultConfigValues(def, undefined, activeEntityType));
setTitle(def.name);
setDescription('');
};
@@ -136,10 +160,55 @@ export function BoardComponentSelect({
? selectedEntityId
: boardEntityId
: undefined;
+ const reportFields = selectedDef?.configFields?.filter(
+ field => field.type === 'report' && field.reportType,
+ );
+ const { data: reportOptionsData, isLoading: isLoadingReportOptions } = useQuery({
+ queryKey: [
+ 'board-component-report-options',
+ {
+ websiteId: resolvedEntityId,
+ reportTypes: reportFields?.map(field => field.reportType).join(','),
+ },
+ ],
+ queryFn: async () => {
+ const types = [...new Set(reportFields?.map(field => field.reportType).filter(Boolean))];
+
+ const entries = await Promise.all(
+ types.map(async type => {
+ const response = await get('/reports', {
+ websiteId: resolvedEntityId,
+ type,
+ pageSize: 1000,
+ });
+
+ return [type, response.data] as const;
+ }),
+ );
+
+ return Object.fromEntries(entries);
+ },
+ enabled: !!resolvedEntityId && !!reportFields?.length,
+ });
+ const reportOptionsByType = reportOptionsData ?? {};
+ const hasRequiredConfig = selectedDef
+ ? (selectedDef.configFields ?? []).every(field => {
+ if (!field.required) {
+ return true;
+ }
+
+ const value = configValues[field.name];
+ return value !== undefined && value !== null && value !== '';
+ })
+ : false;
const handleEntityTypeChange = (value: string) => {
setSelectedEntityType(value as BoardEntityType);
setSelectedEntityId(undefined);
+ setSelectedDef(null);
+ setConfigValues({});
+ setTitle('');
+ setDescription('');
};
const handleAdd = () => {
@@ -186,7 +255,11 @@ export function BoardComponentSelect({
[selectedDef, configValues],
);
- const canSave = !!selectedDef && isSelectedDefSupported && (!needsWebsite || !!resolvedEntityId);
+ const canSave =
+ !!selectedDef &&
+ isSelectedDefSupported &&
+ hasRequiredConfig &&
+ (!needsWebsite || !!resolvedEntityId);
const availableDefinitions = useMemo(
() =>
allDefinitions.filter(
@@ -195,6 +268,33 @@ export function BoardComponentSelect({
),
[activeEntityType, allDefinitions, selectedDef?.type],
);
+ const groupedDefinitions = useMemo(() => {
+ const order = new Map(COMPONENT_GROUP_ORDER.map((group, index) => [group, index]));
+ const groups = new Map();
+
+ for (const def of availableDefinitions) {
+ const group = def.group ?? 'Other';
+ const items = groups.get(group) ?? [];
+ items.push(def);
+ groups.set(group, items);
+ }
+
+ return Array.from(groups.entries())
+ .sort(([a], [b]) => {
+ const left = order.get(a) ?? Number.MAX_SAFE_INTEGER;
+ const right = order.get(b) ?? Number.MAX_SAFE_INTEGER;
+
+ if (left !== right) {
+ return left - right;
+ }
+
+ return a.localeCompare(b);
+ })
+ .map(([group, definitions]) => ({
+ group,
+ definitions,
+ }));
+ }, [availableDefinitions]);
return (
@@ -310,6 +410,35 @@ export function BoardComponentSelect({
style={{ minHeight: 200 }}
/>
)}
+
+ {field.type === 'report' && (
+ <>
+
+ {isLoadingReportOptions && (
+
+ Loading options...
+
+ )}
+ {!isLoadingReportOptions &&
+ resolvedEntityId &&
+ (reportOptionsByType[field.reportType ?? '']?.length ?? 0) === 0 && (
+
+ No saved items found.
+
+ )}
+ >
+ )}
))}
@@ -321,40 +450,47 @@ export function BoardComponentSelect({
{hasSelectedEntity ? (
- {availableDefinitions.map(def => {
- const Icon = def.icon;
+ {groupedDefinitions.map(({ group, definitions }) => (
+
+
+ {group}
+
+ {definitions.map(def => {
+ const Icon = def.icon;
- return (
-
- handleSelectComponent(def)}
- >
-
-
-
+ handleSelectComponent(def)}
>
- {def.name}
-
-
- {def.description}
-
-
-
-
- );
- })}
+
+
+
+ {def.name}
+
+
+ {def.description}
+
+
+
+
+ );
+ })}
+
+ ))}
) : (
diff --git a/src/app/(main)/boards/boardComponentRegistry.tsx b/src/app/(main)/boards/boardComponentRegistry.tsx
index 3607b733a..1af813051 100644
--- a/src/app/(main)/boards/boardComponentRegistry.tsx
+++ b/src/app/(main)/boards/boardComponentRegistry.tsx
@@ -1,30 +1,47 @@
import type { ComponentType } from 'react';
import { TextBlock } from '@/app/(main)/boards/TextBlock';
+import { BoardFunnel } from '@/app/(main)/websites/[websiteId]/(reports)/funnels/BoardFunnel';
import { LinkMetricsBar } from '@/app/(main)/links/[linkId]/LinkMetricsBar';
import { PixelMetricsBar } from '@/app/(main)/pixels/[pixelId]/PixelMetricsBar';
+import { BoardGoal } from '@/app/(main)/websites/[websiteId]/(reports)/goals/BoardGoal';
+import { BoardRevenueChart } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueChart';
+import { BoardRevenueMetricsBar } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsBar';
+import { BoardRevenueMetricsTable } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsTable';
+import { BoardUTM } from '@/app/(main)/websites/[websiteId]/(reports)/utm/BoardUTM';
+import { EventsMetricsBar } from '@/app/(main)/websites/[websiteId]/events/EventsMetricsBar';
+import { BoardRealtimeActiveUsers } from '@/app/(main)/websites/[websiteId]/realtime/BoardRealtimeActiveUsers';
+import { BoardRealtimeChart } from '@/app/(main)/websites/[websiteId]/realtime/BoardRealtimeChart';
+import { BoardRealtimeMetricsBar } from '@/app/(main)/websites/[websiteId]/realtime/BoardRealtimeMetricsBar';
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
import { WebsiteMetricsBar } from '@/app/(main)/websites/[websiteId]/WebsiteMetricsBar';
import {
+ Activity,
Calendar,
ChartColumnBig,
ChartPie,
FileText,
+ GitBranch,
Globe,
PanelTop,
Sheet,
+ Target,
+ Users,
} from '@/components/icons';
import { EventsChart } from '@/components/metrics/EventsChart';
import { MetricsTable } from '@/components/metrics/MetricsTable';
import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic';
import { WorldMap } from '@/components/metrics/WorldMap';
+import { CURRENCIES, DEFAULT_CURRENCY } from '@/lib/constants';
export interface ConfigField {
name: string;
label: string;
- type: 'select' | 'number' | 'text' | 'textarea';
+ type: 'select' | 'number' | 'text' | 'textarea' | 'report';
options?: { label: string; value: string }[];
optionsByEntityType?: Record;
defaultValue?: any;
+ reportType?: string;
+ required?: boolean;
}
export interface ComponentDefinition {
@@ -32,6 +49,7 @@ export interface ComponentDefinition {
name: string;
description: string;
category: string;
+ group?: string;
icon: ComponentType;
component: ComponentType;
componentByEntityType?: Record>;
@@ -76,18 +94,13 @@ const METRIC_TYPES = [
const PIXEL_LINK_METRIC_TYPES = METRIC_TYPES.filter(({ value }) =>
[
'referrer',
- 'country',
- 'region',
- 'city',
+ 'channel',
'browser',
'os',
'device',
- 'query',
- 'utmSource',
- 'utmMedium',
- 'utmCampaign',
- 'utmContent',
- 'utmTerm',
+ 'country',
+ 'region',
+ 'city',
].includes(value),
);
@@ -97,6 +110,26 @@ const LIMIT_OPTIONS = [
{ label: '20', value: '20' },
];
+const UTM_PARAM_OPTIONS = [
+ { label: 'Source', value: 'utm_source' },
+ { label: 'Medium', value: 'utm_medium' },
+ { label: 'Campaign', value: 'utm_campaign' },
+ { label: 'Content', value: 'utm_content' },
+ { label: 'Term', value: 'utm_term' },
+];
+
+const REVENUE_METRIC_TYPE_OPTIONS = [
+ { label: 'Referrers', value: 'referrer' },
+ { label: 'Channels', value: 'channel' },
+ { label: 'Countries', value: 'country' },
+ { label: 'Regions', value: 'region' },
+];
+
+const CURRENCY_OPTIONS = CURRENCIES.map(({ id, name }) => ({
+ label: `${id} - ${name}`,
+ value: id,
+}));
+
const PixelMetricsBarAdapter = ({ websiteId }: { websiteId?: string }) =>
websiteId ? : null;
@@ -110,6 +143,7 @@ const componentDefinitions: ComponentDefinition[] = [
name: 'Metrics bar',
description: 'Key metrics: views, visitors, bounces, time on site',
category: 'overview',
+ group: 'Traffic',
icon: PanelTop,
component: WebsiteMetricsBar,
componentByEntityType: {
@@ -117,14 +151,177 @@ const componentDefinitions: ComponentDefinition[] = [
link: LinkMetricsBarAdapter,
},
},
+ {
+ type: 'EventsMetricsBar',
+ name: 'Event metrics bar',
+ description: 'Key metrics: visitors, visits, events, unique events',
+ category: 'overview',
+ group: 'Events',
+ icon: PanelTop,
+ component: EventsMetricsBar,
+ },
+ {
+ type: 'Goal',
+ name: 'Goal',
+ description: 'Conversion progress for a saved goal',
+ category: 'overview',
+ group: 'Behavior',
+ icon: Target,
+ component: BoardGoal,
+ configFields: [
+ {
+ name: 'reportId',
+ label: 'Saved goal',
+ type: 'report',
+ reportType: 'goal',
+ required: true,
+ },
+ ],
+ },
+ {
+ type: 'Funnel',
+ name: 'Funnel',
+ description: 'Step conversion for a saved funnel',
+ category: 'overview',
+ group: 'Behavior',
+ icon: GitBranch,
+ component: BoardFunnel,
+ configFields: [
+ {
+ name: 'reportId',
+ label: 'Saved funnel',
+ type: 'report',
+ reportType: 'funnel',
+ required: true,
+ },
+ ],
+ },
{
type: 'WebsiteChart',
name: 'Visitors chart',
description: 'Page views and visitors over time',
category: 'overview',
+ group: 'Traffic',
icon: ChartColumnBig,
component: WebsiteChart,
},
+ {
+ type: 'UTM',
+ name: 'UTM',
+ description: 'List and pie chart for one UTM parameter',
+ category: 'visualization',
+ group: 'Growth',
+ icon: ChartPie,
+ component: BoardUTM,
+ defaultProps: { param: 'utm_source', limit: 10 },
+ configFields: [
+ {
+ name: 'param',
+ label: 'UTM param',
+ type: 'select',
+ options: UTM_PARAM_OPTIONS,
+ defaultValue: 'utm_source',
+ },
+ {
+ name: 'limit',
+ label: 'Rows',
+ type: 'select',
+ options: LIMIT_OPTIONS,
+ defaultValue: '10',
+ },
+ ],
+ },
+ {
+ type: 'RealtimeHeader',
+ name: 'Realtime metrics bar',
+ description: 'Live views, visitors, events, and countries',
+ category: 'overview',
+ group: 'Realtime',
+ icon: PanelTop,
+ component: BoardRealtimeMetricsBar,
+ },
+ {
+ type: 'RealtimeChart',
+ name: 'Realtime chart',
+ description: 'Live visitors and page views by minute',
+ category: 'visualization',
+ group: 'Realtime',
+ icon: Activity,
+ component: BoardRealtimeChart,
+ },
+ {
+ type: 'RealtimeActiveUsers',
+ name: 'Active users',
+ description: 'Current visitors online',
+ category: 'overview',
+ group: 'Realtime',
+ icon: Users,
+ component: BoardRealtimeActiveUsers,
+ },
+ {
+ type: 'RevenueMetricsBar',
+ name: 'Revenue metrics bar',
+ description: 'Revenue summary: total, AOV, ARPU, orders, and customers',
+ category: 'overview',
+ group: 'Revenue',
+ icon: PanelTop,
+ component: BoardRevenueMetricsBar,
+ defaultProps: { currency: DEFAULT_CURRENCY },
+ configFields: [
+ {
+ name: 'currency',
+ label: 'Currency',
+ type: 'select',
+ options: CURRENCY_OPTIONS,
+ defaultValue: DEFAULT_CURRENCY,
+ },
+ ],
+ },
+ {
+ type: 'RevenueChart',
+ name: 'Revenue chart',
+ description: 'Revenue over time',
+ category: 'visualization',
+ group: 'Revenue',
+ icon: ChartColumnBig,
+ component: BoardRevenueChart,
+ defaultProps: { currency: DEFAULT_CURRENCY },
+ configFields: [
+ {
+ name: 'currency',
+ label: 'Currency',
+ type: 'select',
+ options: CURRENCY_OPTIONS,
+ defaultValue: DEFAULT_CURRENCY,
+ },
+ ],
+ },
+ {
+ type: 'RevenueMetricsTable',
+ name: 'Revenue metrics table',
+ description: 'Revenue breakdown by source or location',
+ category: 'tables',
+ group: 'Revenue',
+ icon: Sheet,
+ component: BoardRevenueMetricsTable,
+ defaultProps: { type: 'referrer', currency: DEFAULT_CURRENCY },
+ configFields: [
+ {
+ name: 'type',
+ label: 'Metric type',
+ type: 'select',
+ options: REVENUE_METRIC_TYPE_OPTIONS,
+ defaultValue: 'referrer',
+ },
+ {
+ name: 'currency',
+ label: 'Currency',
+ type: 'select',
+ options: CURRENCY_OPTIONS,
+ defaultValue: DEFAULT_CURRENCY,
+ },
+ ],
+ },
// Tables
{
@@ -132,6 +329,7 @@ const componentDefinitions: ComponentDefinition[] = [
name: 'Metrics table',
description: 'Table of metrics by dimension',
category: 'tables',
+ group: 'Traffic',
icon: Sheet,
component: MetricsTable,
defaultProps: { type: 'path', limit: 10 },
@@ -163,6 +361,7 @@ const componentDefinitions: ComponentDefinition[] = [
name: 'World map',
description: 'Geographic distribution of visitors',
category: 'visualization',
+ group: 'Traffic',
icon: Globe,
component: WorldMap,
},
@@ -171,6 +370,7 @@ const componentDefinitions: ComponentDefinition[] = [
name: 'Weekly traffic',
description: 'Traffic heatmap by day and hour',
category: 'visualization',
+ group: 'Traffic',
icon: Calendar,
component: WeeklyTraffic,
},
@@ -179,6 +379,7 @@ const componentDefinitions: ComponentDefinition[] = [
name: 'Events chart',
description: 'Custom events over time',
category: 'visualization',
+ group: 'Events',
icon: ChartPie,
component: EventsChart,
},
@@ -189,6 +390,7 @@ const componentDefinitions: ComponentDefinition[] = [
name: 'Text',
description: 'Free-form text content',
category: 'content',
+ group: 'Content',
icon: FileText,
component: TextBlock,
requiresWebsite: false,
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/BoardFunnel.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/BoardFunnel.tsx
new file mode 100644
index 000000000..8818cf381
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/BoardFunnel.tsx
@@ -0,0 +1,41 @@
+import { Text } from '@umami/react-zen';
+import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useReportQuery } from '@/components/hooks/queries/useReportQuery';
+import { Funnel } from './Funnel';
+
+export function BoardFunnel({ websiteId, reportId }: { websiteId: string; reportId?: string }) {
+ const { data, isLoading, error, isFetching } = useReportQuery(reportId || '');
+
+ if (!reportId) {
+ return (
+
+ );
+ }
+
+ if (data && (data.type !== 'funnel' || data.websiteId !== websiteId)) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {data ? (
+
+ ) : (
+ Funnel unavailable
+ )}
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx
index 2525195fd..01015605c 100644
--- a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx
@@ -15,9 +15,17 @@ interface FunnelProps {
type: string;
parameters: Record;
websiteId: string;
+ allowEdit?: boolean;
}
-export function Funnel({ id, name, type, parameters, websiteId }: FunnelProps) {
+export function Funnel({
+ id,
+ name,
+ type,
+ parameters,
+ websiteId,
+ allowEdit = true,
+}: FunnelProps) {
const { t, labels } = useMessages();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
@@ -39,7 +47,7 @@ export function Funnel({ id, name, type, parameters, websiteId }: FunnelProps) {
- {!isSharePage && (
+ {allowEdit && !isSharePage && (
;
+ }
+
+ if (data && (data.type !== 'goal' || data.websiteId !== websiteId)) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {data ? (
+
+ ) : (
+ Goal unavailable
+ )}
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx
index 5ed1848e9..6983ab4cc 100644
--- a/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx
@@ -19,11 +19,21 @@ export interface GoalProps {
websiteId: string;
startDate: Date;
endDate: Date;
+ allowEdit?: boolean;
}
export type GoalData = { num: number; total: number };
-export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) {
+export function Goal({
+ id,
+ name,
+ type,
+ parameters,
+ websiteId,
+ startDate,
+ endDate,
+ allowEdit = true,
+}: GoalProps) {
const { t, labels } = useMessages();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
@@ -47,7 +57,7 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
- {!isSharePage && (
+ {allowEdit && !isSharePage && (
+ {data && (
+
+ )}
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsBar.tsx
new file mode 100644
index 000000000..e10d79cde
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsBar.tsx
@@ -0,0 +1,27 @@
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useDateRange } from '@/components/hooks';
+import { useRevenueStatsQuery } from '@/components/hooks/queries/useRevenueStatsQuery';
+import { RevenueMetricsBar } from './RevenueMetricsBar';
+import { useBoardRevenueCurrency } from './useBoardRevenueCurrency';
+
+export function BoardRevenueMetricsBar({
+ websiteId,
+ currency: selectedCurrency,
+}: {
+ websiteId: string;
+ currency?: string;
+}) {
+ const currency = useBoardRevenueCurrency(selectedCurrency);
+ const { compare } = useDateRange();
+ const { data, isLoading, isFetching, error } = useRevenueStatsQuery({
+ websiteId,
+ currency,
+ compare,
+ });
+
+ return (
+
+ {data && }
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsTable.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsTable.tsx
new file mode 100644
index 000000000..20a95c191
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsTable.tsx
@@ -0,0 +1,53 @@
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useDateRange, useMessages } from '@/components/hooks';
+import type { RevenueMetricType } from '@/components/hooks/queries/useRevenueMetricsQuery';
+import { useRevenueStatsQuery } from '@/components/hooks/queries/useRevenueStatsQuery';
+import { RevenueMetricsTable } from './RevenueMetricsTable';
+import { useBoardRevenueCurrency } from './useBoardRevenueCurrency';
+
+function getRevenueTableTitle(type: RevenueMetricType, labels: Record, t: (key: string) => string) {
+ switch (type) {
+ case 'referrer':
+ return t(labels.referrer);
+ case 'channel':
+ return t(labels.channel);
+ case 'country':
+ return t(labels.country);
+ case 'region':
+ return t(labels.region);
+ }
+}
+
+export function BoardRevenueMetricsTable({
+ websiteId,
+ type = 'referrer',
+ currency: selectedCurrency,
+}: {
+ websiteId: string;
+ type?: RevenueMetricType;
+ currency?: string;
+}) {
+ const currency = useBoardRevenueCurrency(selectedCurrency);
+ const { compare } = useDateRange();
+ const { t, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useRevenueStatsQuery({
+ websiteId,
+ currency,
+ compare,
+ });
+
+ return (
+
+ {data && (
+
+ )}
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/useBoardRevenueCurrency.ts b/src/app/(main)/websites/[websiteId]/(reports)/revenue/useBoardRevenueCurrency.ts
new file mode 100644
index 000000000..a4a50c4f7
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/useBoardRevenueCurrency.ts
@@ -0,0 +1,6 @@
+import { CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants';
+import { getItem } from '@/lib/storage';
+
+export function useBoardRevenueCurrency(currency?: string) {
+ return currency || getItem(CURRENCY_CONFIG) || process.env.defaultCurrency || DEFAULT_CURRENCY;
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/BoardUTM.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/BoardUTM.tsx
new file mode 100644
index 000000000..5444e5e71
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/BoardUTM.tsx
@@ -0,0 +1,112 @@
+import { Column, Heading, Text } from '@umami/react-zen';
+import { useEffect, useRef, useState } from 'react';
+import { PieChart } from '@/components/charts/PieChart';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useDateRange, useMessages, useResultQuery } from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { CHART_COLORS } from '@/lib/constants';
+
+const STACKED_LAYOUT_WIDTH = 900;
+
+export function BoardUTM({
+ websiteId,
+ param = 'utm_source',
+ limit = 10,
+}: {
+ websiteId: string;
+ param?: string;
+ limit?: number | string;
+}) {
+ const wrapperRef = useRef(null);
+ const [isStacked, setIsStacked] = useState(false);
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+ const { t, labels } = useMessages();
+ const { data, error, isLoading } = useResultQuery('utm', {
+ websiteId,
+ startDate,
+ endDate,
+ });
+
+ const itemLimit = Number(limit) || 10;
+ const items = (data?.[param] ?? []).slice(0, itemLimit);
+ const total = items.reduce((sum, { views }) => {
+ return +sum + +views;
+ }, 0);
+ const chartData = {
+ labels: items.map(({ utm }) => utm),
+ datasets: [
+ {
+ data: items.map(({ views }) => views),
+ backgroundColor: CHART_COLORS,
+ borderWidth: 0,
+ },
+ ],
+ };
+
+ useEffect(() => {
+ const updateLayout = () => {
+ const width = wrapperRef.current?.clientWidth ?? 0;
+
+ setIsStacked(current => {
+ const next = width > 0 && width < STACKED_LAYOUT_WIDTH;
+ return current === next ? current : next;
+ });
+ };
+
+ updateLayout();
+
+ const resizeObserver =
+ typeof ResizeObserver !== 'undefined' ? new ResizeObserver(updateLayout) : null;
+
+ if (wrapperRef.current && resizeObserver) {
+ resizeObserver.observe(wrapperRef.current);
+ }
+
+ window.addEventListener('resize', updateLayout);
+
+ return () => {
+ resizeObserver?.disconnect();
+ window.removeEventListener('resize', updateLayout);
+ };
+ }, []);
+
+ return (
+
+
+
+
+
+ {param.replace(/^utm_/, '')}
+
+ ({
+ label: utm,
+ count: views,
+ percent: total ? (views / total) * 100 : 0,
+ }))}
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx
index ff5bd74f1..d6b4cf566 100644
--- a/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx
+++ b/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx
@@ -1,40 +1,70 @@
import { LoadingPanel } from '@/components/common/LoadingPanel';
-import { useMessages } from '@/components/hooks';
-import { useWebsiteSessionStatsQuery } from '@/components/hooks/queries/useWebsiteSessionStatsQuery';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar';
import { formatLongNumber } from '@/lib/format';
export function EventsMetricsBar({ websiteId }: { websiteId: string }) {
- const { t, labels } = useMessages();
- const { data, isLoading, isFetching, error } = useWebsiteSessionStatsQuery(websiteId);
+ const { isAllTime } = useDateRange();
+ const { t, labels, getErrorMessage } = useMessages();
+ const { data, isLoading, isFetching, error } = useEventStatsQuery({
+ websiteId,
+ });
+
+ const { events, visitors, visits, uniqueEvents, comparison } = data || {};
+
+ const metrics = data
+ ? [
+ {
+ value: visitors,
+ label: t(labels.visitors),
+ change: visitors - comparison.visitors,
+ formatValue: formatLongNumber,
+ },
+ {
+ value: visits,
+ label: t(labels.visits),
+ change: visits - comparison.visits,
+ formatValue: formatLongNumber,
+ },
+ {
+ value: events,
+ label: t(labels.events),
+ change: events - comparison.events,
+ formatValue: formatLongNumber,
+ },
+ {
+ value: uniqueEvents,
+ label: t(labels.uniqueEvents),
+ change: uniqueEvents - comparison.uniqueEvents,
+ formatValue: formatLongNumber,
+ },
+ ]
+ : null;
return (
-
- {data && (
-
-
-
-
-
-
- )}
+
+
+ {metrics?.map(({ label, value, change, formatValue }) => {
+ return (
+
+ );
+ })}
+
);
}
diff --git a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx
index f359b9cb0..2f653665d 100644
--- a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx
+++ b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx
@@ -3,90 +3,30 @@ import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import { type Key, useState } from 'react';
import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
-import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
-import { useDateRange, useMessages } from '@/components/hooks';
-import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery';
+import { useMessages } from '@/components/hooks';
import { EventsChart } from '@/components/metrics/EventsChart';
-import { MetricCard } from '@/components/metrics/MetricCard';
-import { MetricsBar } from '@/components/metrics/MetricsBar';
import { MetricsTable } from '@/components/metrics/MetricsTable';
-import { formatLongNumber } from '@/lib/format';
import { getItem, setItem } from '@/lib/storage';
import { EventProperties } from './EventProperties';
import { EventsDataTable } from './EventsDataTable';
+import { EventsMetricsBar } from './EventsMetricsBar';
const KEY_NAME = 'umami.events.tab';
export function EventsPage({ websiteId }) {
const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart');
- const { isAllTime } = useDateRange();
- const { t, labels, getErrorMessage } = useMessages();
- const { data, isLoading, isFetching, error } = useEventStatsQuery({
- websiteId,
- });
+ const { t, labels } = useMessages();
const handleSelect = (value: Key) => {
setItem(KEY_NAME, value);
setTab(value);
};
- const { events, visitors, visits, uniqueEvents, comparison } = data || {};
-
- const metrics = data
- ? [
- {
- value: visitors,
- label: t(labels.visitors),
- change: visitors - comparison.visitors,
- formatValue: formatLongNumber,
- },
- {
- value: visits,
- label: t(labels.visits),
- change: visits - comparison.visits,
- formatValue: formatLongNumber,
- },
- {
- value: events,
- label: t(labels.events),
- change: events - comparison.events,
- formatValue: formatLongNumber,
- },
- {
- value: uniqueEvents,
- label: t(labels.uniqueEvents),
- change: uniqueEvents - comparison.uniqueEvents,
- formatValue: formatLongNumber,
- },
- ]
- : null;
-
return (
-
-
- {metrics?.map(({ label, value, change, formatValue }) => {
- return (
-
- );
- })}
-
-
+
+
+
+ {count}
+
+
+ {t(labels.online)}
+
+
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/BoardRealtimeChart.tsx b/src/app/(main)/websites/[websiteId]/realtime/BoardRealtimeChart.tsx
new file mode 100644
index 000000000..c0f142804
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/BoardRealtimeChart.tsx
@@ -0,0 +1,13 @@
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useRealtimeQuery } from '@/components/hooks';
+import { RealtimeChart } from '@/components/metrics/RealtimeChart';
+
+export function BoardRealtimeChart({ websiteId }: { websiteId: string }) {
+ const { data, isLoading, error } = useRealtimeQuery(websiteId);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/BoardRealtimeMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/realtime/BoardRealtimeMetricsBar.tsx
new file mode 100644
index 000000000..15ffedf6d
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/BoardRealtimeMetricsBar.tsx
@@ -0,0 +1,13 @@
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useRealtimeQuery } from '@/components/hooks';
+import { RealtimeHeader } from './RealtimeHeader';
+
+export function BoardRealtimeMetricsBar({ websiteId }: { websiteId: string }) {
+ const { data, isLoading, error } = useRealtimeQuery(websiteId);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/api/reports/[reportId]/route.ts b/src/app/api/reports/[reportId]/route.ts
index d3384f7d6..3de9a017f 100644
--- a/src/app/api/reports/[reportId]/route.ts
+++ b/src/app/api/reports/[reportId]/route.ts
@@ -15,6 +15,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ repo
const report = await getReport(reportId);
+ if (!report) {
+ return notFound();
+ }
+
if (!(await canViewReport(auth, report))) {
return unauthorized();
}
diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts
index 353f329e0..405be6623 100644
--- a/src/app/api/reports/route.ts
+++ b/src/app/api/reports/route.ts
@@ -4,33 +4,13 @@ import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, reportSchema, reportTypeParam } from '@/lib/schema';
import {
+ getReportSection,
canUpdateWebsite,
canViewAuthenticatedWebsite,
canViewWebsiteSection,
} from '@/permissions';
-import type { ShareSection } from '@/permissions';
import { createReport, getReports } from '@/queries/prisma';
-function getReportSection(type?: z.infer): ShareSection | null {
- switch (type) {
- case 'attribution':
- case 'breakdown':
- case 'performance':
- case 'retention':
- case 'revenue':
- case 'utm':
- return type;
- case 'funnel':
- return 'funnels';
- case 'goal':
- return 'goals';
- case 'journey':
- return 'journeys';
- default:
- return null;
- }
-}
-
export async function GET(request: Request) {
const schema = z.object({
websiteId: z.uuid(),
diff --git a/src/lib/boardComponentCompatibility.ts b/src/lib/boardComponentCompatibility.ts
index 943701962..d04ced5e5 100644
--- a/src/lib/boardComponentCompatibility.ts
+++ b/src/lib/boardComponentCompatibility.ts
@@ -1,7 +1,18 @@
import type { BoardEntityType } from './boards';
export const BOARD_COMPONENT_COMPATIBILITY_MATRIX = {
+ EventsMetricsBar: ['website'],
EventsChart: ['website'],
+ Funnel: ['website'],
+ Goal: ['website'],
+ RealtimeActiveUsers: ['website'],
+ RealtimeHeader: ['website'],
+ RealtimeChart: ['website'],
+ RevenueMetricsBar: ['website'],
+ RevenueChart: ['website'],
+ RevenueMetricsTable: ['website'],
+ UTM: ['website'],
+ WeeklyTraffic: ['website'],
} as const satisfies Partial>;
export function getSupportedBoardComponentEntityTypes(componentType: string) {
diff --git a/src/lib/boards.test.ts b/src/lib/boards.test.ts
index ce7d94fd1..f80e7c79e 100644
--- a/src/lib/boards.test.ts
+++ b/src/lib/boards.test.ts
@@ -1,4 +1,5 @@
import { expect, test } from 'vitest';
+import { getComponentDefinition } from '@/app/(main)/boards/boardComponentRegistry';
import {
BOARD_COMPONENT_COMPATIBILITY_MATRIX,
getSupportedBoardComponentEntityTypes,
@@ -14,14 +15,101 @@ test('isBoardComponentSupported rejects events chart on pixel and link boards',
expect(isBoardComponentSupported('EventsChart', BOARD_ENTITY_TYPES.link)).toBe(false);
});
+test('isBoardComponentSupported rejects event metrics bar and weekly traffic on pixel and link boards', () => {
+ expect(isBoardComponentSupported('EventsMetricsBar', BOARD_ENTITY_TYPES.pixel)).toBe(false);
+ expect(isBoardComponentSupported('EventsMetricsBar', BOARD_ENTITY_TYPES.link)).toBe(false);
+ expect(isBoardComponentSupported('WeeklyTraffic', BOARD_ENTITY_TYPES.pixel)).toBe(false);
+ expect(isBoardComponentSupported('WeeklyTraffic', BOARD_ENTITY_TYPES.link)).toBe(false);
+});
+
test('board component compatibility matrix defines website-only components explicitly', () => {
+ expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.EventsMetricsBar).toEqual([
+ BOARD_ENTITY_TYPES.website,
+ ]);
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.EventsChart).toEqual([BOARD_ENTITY_TYPES.website]);
+ expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.Funnel).toEqual([BOARD_ENTITY_TYPES.website]);
+ expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.Goal).toEqual([BOARD_ENTITY_TYPES.website]);
+ expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RealtimeActiveUsers).toEqual([
+ BOARD_ENTITY_TYPES.website,
+ ]);
+ expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RealtimeHeader).toEqual([
+ BOARD_ENTITY_TYPES.website,
+ ]);
+ expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RealtimeChart).toEqual([
+ BOARD_ENTITY_TYPES.website,
+ ]);
+ expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RevenueMetricsBar).toEqual([
+ BOARD_ENTITY_TYPES.website,
+ ]);
+ expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RevenueChart).toEqual([
+ BOARD_ENTITY_TYPES.website,
+ ]);
+ expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RevenueMetricsTable).toEqual([
+ BOARD_ENTITY_TYPES.website,
+ ]);
+ expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.UTM).toEqual([BOARD_ENTITY_TYPES.website]);
+ expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.WeeklyTraffic).toEqual([
+ BOARD_ENTITY_TYPES.website,
+ ]);
+ expect(getSupportedBoardComponentEntityTypes('EventsMetricsBar')).toEqual([
+ BOARD_ENTITY_TYPES.website,
+ ]);
expect(getSupportedBoardComponentEntityTypes('EventsChart')).toEqual([
BOARD_ENTITY_TYPES.website,
]);
+ expect(getSupportedBoardComponentEntityTypes('Funnel')).toEqual([BOARD_ENTITY_TYPES.website]);
+ expect(getSupportedBoardComponentEntityTypes('Goal')).toEqual([BOARD_ENTITY_TYPES.website]);
+ expect(getSupportedBoardComponentEntityTypes('RealtimeActiveUsers')).toEqual([
+ BOARD_ENTITY_TYPES.website,
+ ]);
+ expect(getSupportedBoardComponentEntityTypes('RealtimeHeader')).toEqual([
+ BOARD_ENTITY_TYPES.website,
+ ]);
+ expect(getSupportedBoardComponentEntityTypes('RealtimeChart')).toEqual([
+ BOARD_ENTITY_TYPES.website,
+ ]);
+ expect(getSupportedBoardComponentEntityTypes('RevenueMetricsBar')).toEqual([
+ BOARD_ENTITY_TYPES.website,
+ ]);
+ expect(getSupportedBoardComponentEntityTypes('RevenueChart')).toEqual([
+ BOARD_ENTITY_TYPES.website,
+ ]);
+ expect(getSupportedBoardComponentEntityTypes('RevenueMetricsTable')).toEqual([
+ BOARD_ENTITY_TYPES.website,
+ ]);
+ expect(getSupportedBoardComponentEntityTypes('UTM')).toEqual([BOARD_ENTITY_TYPES.website]);
+ expect(getSupportedBoardComponentEntityTypes('WeeklyTraffic')).toEqual([
+ BOARD_ENTITY_TYPES.website,
+ ]);
});
test('isBoardComponentSupported leaves other components available for all board entities', () => {
expect(isBoardComponentSupported('WebsiteChart', BOARD_ENTITY_TYPES.pixel)).toBe(true);
expect(isBoardComponentSupported('TextBlock', BOARD_ENTITY_TYPES.link)).toBe(true);
});
+
+test('metrics table limits pixel and link boards to overview-page metric types', () => {
+ const definition = getComponentDefinition('MetricsTable');
+ const options = definition?.configFields?.find(field => field.name === 'type')?.optionsByEntityType;
+
+ expect(options?.pixel?.map(option => option.value)).toEqual([
+ 'referrer',
+ 'channel',
+ 'country',
+ 'region',
+ 'city',
+ 'browser',
+ 'os',
+ 'device',
+ ]);
+ expect(options?.link?.map(option => option.value)).toEqual([
+ 'referrer',
+ 'channel',
+ 'country',
+ 'region',
+ 'city',
+ 'browser',
+ 'os',
+ 'device',
+ ]);
+});
diff --git a/src/permissions/report.test.ts b/src/permissions/report.test.ts
new file mode 100644
index 000000000..46be11227
--- /dev/null
+++ b/src/permissions/report.test.ts
@@ -0,0 +1,125 @@
+import { expect, test, vi } from 'vitest';
+import { ENTITY_TYPE } from '@/lib/constants';
+import { canViewReport, getReportSection } from './report';
+import { canViewWebsite } from './website';
+
+vi.mock('./website', () => ({
+ canViewWebsite: vi.fn(),
+}));
+
+test('getReportSection maps saved report types to share sections', () => {
+ expect(getReportSection('goal')).toBe('goals');
+ expect(getReportSection('funnel')).toBe('funnels');
+ expect(getReportSection('journey')).toBe('journeys');
+ expect(getReportSection('revenue')).toBe('revenue');
+ expect(getReportSection('heatmap')).toBe(null);
+});
+
+test('canViewReport allows board shares to fetch included saved goal reports', async () => {
+ await expect(
+ canViewReport(
+ {
+ shareToken: {
+ shareType: ENTITY_TYPE.board,
+ websiteIds: ['website-1'],
+ parameters: {},
+ },
+ },
+ {
+ id: 'report-1',
+ userId: 'owner-1',
+ websiteId: 'website-1',
+ type: 'goal',
+ } as any,
+ ),
+ ).resolves.toBe(true);
+});
+
+test('canViewReport respects share section flags for saved funnel and goal reports', async () => {
+ await expect(
+ canViewReport(
+ {
+ shareToken: {
+ shareType: ENTITY_TYPE.website,
+ websiteId: 'website-1',
+ parameters: {
+ overview: true,
+ funnels: false,
+ goals: false,
+ },
+ },
+ },
+ {
+ id: 'report-1',
+ userId: 'owner-1',
+ websiteId: 'website-1',
+ type: 'funnel',
+ } as any,
+ ),
+ ).resolves.toBe(false);
+
+ await expect(
+ canViewReport(
+ {
+ shareToken: {
+ shareType: ENTITY_TYPE.website,
+ websiteId: 'website-1',
+ parameters: {
+ overview: true,
+ funnels: false,
+ goals: true,
+ },
+ },
+ },
+ {
+ id: 'report-2',
+ userId: 'owner-1',
+ websiteId: 'website-1',
+ type: 'goal',
+ } as any,
+ ),
+ ).resolves.toBe(true);
+});
+
+test('canViewReport denies share-token access to report types without a share section', async () => {
+ await expect(
+ canViewReport(
+ {
+ shareToken: {
+ shareType: ENTITY_TYPE.website,
+ websiteId: 'website-1',
+ parameters: {},
+ },
+ },
+ {
+ id: 'report-1',
+ userId: 'owner-1',
+ websiteId: 'website-1',
+ type: 'heatmap',
+ } as any,
+ ),
+ ).resolves.toBe(false);
+});
+
+test('canViewReport falls back to website access for authenticated users', async () => {
+ vi.mocked(canViewWebsite).mockResolvedValue(true);
+
+ await expect(
+ canViewReport(
+ {
+ user: {
+ id: 'user-1',
+ username: 'user',
+ role: 'user',
+ isAdmin: false,
+ },
+ },
+ {
+ id: 'report-1',
+ userId: 'owner-1',
+ websiteId: 'website-1',
+ type: 'heatmap',
+ } as any,
+ ),
+ ).resolves.toBe(true);
+});
diff --git a/src/permissions/report.ts b/src/permissions/report.ts
index fec37d8ca..cdec31ddc 100644
--- a/src/permissions/report.ts
+++ b/src/permissions/report.ts
@@ -1,8 +1,34 @@
import type { Report } from '@/generated/prisma/client';
import type { Auth } from '@/lib/types';
+import type { ShareSection } from './share';
+import { canViewWebsiteSection } from './share';
import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from './website';
-export async function canViewReport(auth: Auth, report: Report) {
+export function getReportSection(type?: string): ShareSection | null {
+ switch (type) {
+ case 'attribution':
+ case 'breakdown':
+ case 'performance':
+ case 'retention':
+ case 'revenue':
+ case 'utm':
+ return type;
+ case 'funnel':
+ return 'funnels';
+ case 'goal':
+ return 'goals';
+ case 'journey':
+ return 'journeys';
+ default:
+ return null;
+ }
+}
+
+export async function canViewReport(auth: Auth, report: Report | null) {
+ if (!report) {
+ return false;
+ }
+
if (auth.user?.isAdmin) {
return true;
}
@@ -11,7 +37,13 @@ export async function canViewReport(auth: Auth, report: Report) {
return true;
}
- return !!(await canViewWebsite(auth, report.websiteId));
+ const section = getReportSection(report.type);
+
+ if (section) {
+ return !!(await canViewWebsiteSection(auth, report.websiteId, section));
+ }
+
+ return !!auth.user && !!(await canViewWebsite(auth, report.websiteId));
}
export async function canUpdateReport(auth: Auth, report: Report) {