diff --git a/.gitignore b/.gitignore index 377e4be6b..fcb577bad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,49 +1,49 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -node_modules -.pnp -.pnp.js -.pnpm-store -package-lock.json - -# testing -/coverage - -# next.js -/.next -/out - -# production -/build -/public/script.js -/geo -/dist -/generated -/src/generated -pm2.yml - -# misc -.DS_Store -.idea -.yarn -*.iml -*.log -.vscode -.tool-versions -.claude -tmpclaude* -nul - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# local env files -.env -.env.* -*.env.* - -*.dev.yml - +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +.pnp +.pnp.js +.pnpm-store +package-lock.json + +# testing +/coverage + +# next.js +/.next +/out + +# production +/build +/public/script.js +/geo +/dist +/generated +/src/generated +pm2.yml + +# misc +.DS_Store +.idea +.yarn +*.iml +*.log +.vscode +.tool-versions +.claude +tmpclaude* +nul + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env +.env.* +*.env.* + +*.dev.yml + diff --git a/prisma/migrations/15_add_share/migration.sql b/prisma/migrations/15_add_share/migration.sql index d9f1e7cf7..89aece1ee 100644 --- a/prisma/migrations/15_add_share/migration.sql +++ b/prisma/migrations/15_add_share/migration.sql @@ -2,6 +2,7 @@ CREATE TABLE "share" ( "share_id" UUID NOT NULL, "entity_id" UUID NOT NULL, + "name" VARCHAR(200) NOT NULL, "share_type" INTEGER NOT NULL, "slug" VARCHAR(100) NOT NULL, "parameters" JSONB NOT NULL, @@ -11,9 +12,6 @@ CREATE TABLE "share" ( CONSTRAINT "share_pkey" PRIMARY KEY ("share_id") ); --- CreateIndex -CREATE UNIQUE INDEX "share_share_id_key" ON "share"("share_id"); - -- CreateIndex CREATE UNIQUE INDEX "share_slug_key" ON "share"("slug"); @@ -21,12 +19,13 @@ CREATE UNIQUE INDEX "share_slug_key" ON "share"("slug"); CREATE INDEX "share_entity_id_idx" ON "share"("entity_id"); -- MigrateData -INSERT INTO "share" (share_id, entity_id, share_type, slug, parameters, created_at) +INSERT INTO "share" (share_id, entity_id, name, share_type, slug, parameters, created_at) SELECT gen_random_uuid(), website_id, + name, 1, share_id, - '{}'::jsonb, + '{"overview":true}'::jsonb, now() FROM "website" WHERE share_id IS NOT NULL; diff --git a/prisma/migrations/17_remove_duplicate_key/migration.sql b/prisma/migrations/17_remove_duplicate_key/migration.sql new file mode 100644 index 000000000..75f7191ea --- /dev/null +++ b/prisma/migrations/17_remove_duplicate_key/migration.sql @@ -0,0 +1,29 @@ +-- DropIndex +DROP INDEX "link_link_id_key"; + +-- DropIndex +DROP INDEX "pixel_pixel_id_key"; + +-- DropIndex +DROP INDEX "report_report_id_key"; + +-- DropIndex +DROP INDEX "revenue_revenue_id_key"; + +-- DropIndex +DROP INDEX "segment_segment_id_key"; + +-- DropIndex +DROP INDEX "session_session_id_key"; + +-- DropIndex +DROP INDEX "team_team_id_key"; + +-- DropIndex +DROP INDEX "team_user_team_user_id_key"; + +-- DropIndex +DROP INDEX "user_user_id_key"; + +-- DropIndex +DROP INDEX "website_website_id_key"; diff --git a/scripts/build-geo.js b/scripts/build-geo.js index a83caa6c0..e36b097c2 100644 --- a/scripts/build-geo.js +++ b/scripts/build-geo.js @@ -3,7 +3,7 @@ import 'dotenv/config'; import fs from 'node:fs'; import path from 'node:path'; import https from 'https'; -import tar from 'tar'; +import { list } from 'tar'; import zlib from 'zlib'; if (process.env.VERCEL && !process.env.BUILD_GEO) { @@ -40,7 +40,7 @@ const isDirectMmdb = url.endsWith('.mmdb'); const downloadCompressed = url => new Promise(resolve => { https.get(url, res => { - resolve(res.pipe(zlib.createGunzip({})).pipe(tar.t())); + resolve(res.pipe(zlib.createGunzip({})).pipe(list())); }); }); diff --git a/src/app/(main)/admin/teams/AdminTeamsPage.tsx b/src/app/(main)/admin/teams/AdminTeamsPage.tsx index 41e6f4afb..7905f7f63 100644 --- a/src/app/(main)/admin/teams/AdminTeamsPage.tsx +++ b/src/app/(main)/admin/teams/AdminTeamsPage.tsx @@ -3,14 +3,19 @@ import { Column } from '@umami/react-zen'; import { PageHeader } from '@/components/common/PageHeader'; import { Panel } from '@/components/common/Panel'; import { useMessages } from '@/components/hooks'; +import { TeamsAddButton } from '../../teams/TeamsAddButton'; import { AdminTeamsDataTable } from './AdminTeamsDataTable'; export function AdminTeamsPage() { const { formatMessage, labels } = useMessages(); + const handleSave = () => {}; + return ( - + + + diff --git a/src/app/(main)/admin/users/UserAddForm.tsx b/src/app/(main)/admin/users/UserAddForm.tsx index 6c365510c..84b8399cd 100644 --- a/src/app/(main)/admin/users/UserAddForm.tsx +++ b/src/app/(main)/admin/users/UserAddForm.tsx @@ -10,6 +10,7 @@ import { TextField, } from '@umami/react-zen'; import { useMessages, useUpdateQuery } from '@/components/hooks'; +import { messages } from '@/components/messages'; import { ROLES } from '@/lib/constants'; export function UserAddForm({ onSave, onClose }) { @@ -37,7 +38,10 @@ export function UserAddForm({ onSave, onClose }) { diff --git a/src/app/(main)/links/LinksDataTable.tsx b/src/app/(main)/links/LinksDataTable.tsx index 0b3d660bb..87da5984c 100644 --- a/src/app/(main)/links/LinksDataTable.tsx +++ b/src/app/(main)/links/LinksDataTable.tsx @@ -2,13 +2,13 @@ import { DataGrid } from '@/components/common/DataGrid'; import { useLinksQuery, useNavigation } from '@/components/hooks'; import { LinksTable } from './LinksTable'; -export function LinksDataTable() { +export function LinksDataTable({ showActions = false }: { showActions?: boolean }) { const { teamId } = useNavigation(); const query = useLinksQuery({ teamId }); return ( - {({ data }) => } + {({ data }) => } ); } diff --git a/src/app/(main)/links/LinksPage.tsx b/src/app/(main)/links/LinksPage.tsx index a6e4c7c4e..cdaf8fced 100644 --- a/src/app/(main)/links/LinksPage.tsx +++ b/src/app/(main)/links/LinksPage.tsx @@ -4,21 +4,30 @@ import { LinksDataTable } from '@/app/(main)/links/LinksDataTable'; import { PageBody } from '@/components/common/PageBody'; import { PageHeader } from '@/components/common/PageHeader'; import { Panel } from '@/components/common/Panel'; -import { useMessages, useNavigation } from '@/components/hooks'; +import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; import { LinkAddButton } from './LinkAddButton'; export function LinksPage() { + const { user } = useLoginQuery(); const { formatMessage, labels } = useMessages(); const { teamId } = useNavigation(); + const { data } = useTeamMembersQuery(teamId); + + const showActions = + (teamId && + data?.data.filter(team => team.userId === user.id && team.role !== ROLES.teamViewOnly) + .length > 0) || + (!teamId && user.role !== ROLES.viewOnly); return ( - + {showActions && } - + diff --git a/src/app/(main)/links/LinksTable.tsx b/src/app/(main)/links/LinksTable.tsx index a3b4a86ad..62eb0fb86 100644 --- a/src/app/(main)/links/LinksTable.tsx +++ b/src/app/(main)/links/LinksTable.tsx @@ -6,7 +6,11 @@ import { useMessages, useNavigation, useSlug } from '@/components/hooks'; import { LinkDeleteButton } from './LinkDeleteButton'; import { LinkEditButton } from './LinkEditButton'; -export function LinksTable(props: DataTableProps) { +export interface LinksTableProps extends DataTableProps { + showActions?: boolean; +} + +export function LinksTable({ showActions, ...props }: LinksTableProps) { const { formatMessage, labels } = useMessages(); const { websiteId, renderUrl } = useNavigation(); const { getSlugUrl } = useSlug('link'); @@ -36,16 +40,18 @@ export function LinksTable(props: DataTableProps) { {(row: any) => } - - {({ id, name }: any) => { - return ( - - - - - ); - }} - + {showActions && ( + + {({ id, name }: any) => { + return ( + + + + + ); + }} + + )} ); } diff --git a/src/app/(main)/pixels/PixelsDataTable.tsx b/src/app/(main)/pixels/PixelsDataTable.tsx index 51b8c5a0b..6a9a9162d 100644 --- a/src/app/(main)/pixels/PixelsDataTable.tsx +++ b/src/app/(main)/pixels/PixelsDataTable.tsx @@ -2,13 +2,13 @@ import { DataGrid } from '@/components/common/DataGrid'; import { useNavigation, usePixelsQuery } from '@/components/hooks'; import { PixelsTable } from './PixelsTable'; -export function PixelsDataTable() { +export function PixelsDataTable({ showActions = false }: { showActions?: boolean }) { const { teamId } = useNavigation(); const query = usePixelsQuery({ teamId }); return ( - {({ data }) => } + {({ data }) => } ); } diff --git a/src/app/(main)/pixels/PixelsPage.tsx b/src/app/(main)/pixels/PixelsPage.tsx index 4f6acefe5..91ddcdcda 100644 --- a/src/app/(main)/pixels/PixelsPage.tsx +++ b/src/app/(main)/pixels/PixelsPage.tsx @@ -3,22 +3,31 @@ import { Column } from '@umami/react-zen'; import { PageBody } from '@/components/common/PageBody'; import { PageHeader } from '@/components/common/PageHeader'; import { Panel } from '@/components/common/Panel'; -import { useMessages, useNavigation } from '@/components/hooks'; +import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; import { PixelAddButton } from './PixelAddButton'; import { PixelsDataTable } from './PixelsDataTable'; export function PixelsPage() { + const { user } = useLoginQuery(); const { formatMessage, labels } = useMessages(); const { teamId } = useNavigation(); + const { data } = useTeamMembersQuery(teamId); + + const showActions = + (teamId && + data?.data.filter(team => team.userId === user.id && team.role !== ROLES.teamViewOnly) + .length > 0) || + (!teamId && user.role !== ROLES.viewOnly); return ( - + {showActions && } - + diff --git a/src/app/(main)/pixels/PixelsTable.tsx b/src/app/(main)/pixels/PixelsTable.tsx index 48a845896..018b40eb5 100644 --- a/src/app/(main)/pixels/PixelsTable.tsx +++ b/src/app/(main)/pixels/PixelsTable.tsx @@ -6,7 +6,11 @@ import { useMessages, useNavigation, useSlug } from '@/components/hooks'; import { PixelDeleteButton } from './PixelDeleteButton'; import { PixelEditButton } from './PixelEditButton'; -export function PixelsTable(props: DataTableProps) { +export interface PixelsTableProps extends DataTableProps { + showActions?: boolean; +} + +export function PixelsTable({ showActions, ...props }: PixelsTableProps) { const { formatMessage, labels } = useMessages(); const { renderUrl } = useNavigation(); const { getSlugUrl } = useSlug('pixel'); @@ -31,18 +35,20 @@ export function PixelsTable(props: DataTableProps) { {(row: any) => } - - {(row: any) => { - const { id, name } = row; + {showActions && ( + + {(row: any) => { + const { id, name } = row; - return ( - - - - - ); - }} - + return ( + + + + + ); + }} + + )} ); } diff --git a/src/app/(main)/teams/TeamAddForm.tsx b/src/app/(main)/teams/TeamAddForm.tsx index c95259f47..3b827776b 100644 --- a/src/app/(main)/teams/TeamAddForm.tsx +++ b/src/app/(main)/teams/TeamAddForm.tsx @@ -7,8 +7,17 @@ import { TextField, } from '@umami/react-zen'; import { useMessages, useUpdateQuery } from '@/components/hooks'; +import { UserSelect } from '@/components/input/UserSelect'; -export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) { +export function TeamAddForm({ + onSave, + onClose, + isAdmin, +}: { + onSave: () => void; + onClose: () => void; + isAdmin: boolean; +}) { const { formatMessage, labels, getErrorMessage } = useMessages(); const { mutateAsync, error, isPending } = useUpdateQuery('/teams'); @@ -26,6 +35,11 @@ export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose: + {isAdmin && ( + + + + )} + + {formatMessage(labels.save)} + + + + ); +} diff --git a/src/app/(main)/teams/TeamsAddButton.tsx b/src/app/(main)/teams/TeamsAddButton.tsx index 578a273a1..138730887 100644 --- a/src/app/(main)/teams/TeamsAddButton.tsx +++ b/src/app/(main)/teams/TeamsAddButton.tsx @@ -4,7 +4,13 @@ import { Plus } from '@/components/icons'; import { messages } from '@/components/messages'; import { TeamAddForm } from './TeamAddForm'; -export function TeamsAddButton({ onSave }: { onSave?: () => void }) { +export function TeamsAddButton({ + onSave, + isAdmin = false, +}: { + onSave?: () => void; + isAdmin?: boolean; +}) { const { formatMessage, labels } = useMessages(); const { toast } = useToast(); const { touch } = useModified(); @@ -25,7 +31,7 @@ export function TeamsAddButton({ onSave }: { onSave?: () => void }) { - {({ close }) => } + {({ close }) => } diff --git a/src/app/(main)/teams/TeamsMemberAddButton.tsx b/src/app/(main)/teams/TeamsMemberAddButton.tsx new file mode 100644 index 000000000..f1bbf2586 --- /dev/null +++ b/src/app/(main)/teams/TeamsMemberAddButton.tsx @@ -0,0 +1,40 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen'; +import { useMessages, useModified } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { messages } from '@/components/messages'; +import { TeamMemberAddForm } from './TeamMemberAddForm'; + +export function TeamsMemberAddButton({ + teamId, + onSave, +}: { + teamId: string; + onSave?: () => void; + isAdmin?: boolean; +}) { + const { formatMessage, labels } = useMessages(); + const { toast } = useToast(); + const { touch } = useModified(); + + const handleSave = async () => { + toast(formatMessage(messages.saved)); + touch('teams:members'); + onSave?.(); + }; + + return ( + + + + + {({ close }) => } + + + + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamSettings.tsx b/src/app/(main)/teams/[teamId]/TeamSettings.tsx index 3ddbe000b..4bbb8905a 100644 --- a/src/app/(main)/teams/[teamId]/TeamSettings.tsx +++ b/src/app/(main)/teams/[teamId]/TeamSettings.tsx @@ -1,10 +1,12 @@ -import { Column } from '@umami/react-zen'; +import { Column, Heading, Row } from '@umami/react-zen'; import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton'; import { PageHeader } from '@/components/common/PageHeader'; import { Panel } from '@/components/common/Panel'; -import { useLoginQuery, useNavigation, useTeam } from '@/components/hooks'; +import { useLoginQuery, useMessages, useNavigation, useTeam } from '@/components/hooks'; import { Users } from '@/components/icons'; +import { labels } from '@/components/messages'; import { ROLES } from '@/lib/constants'; +import { TeamsMemberAddButton } from '../TeamsMemberAddButton'; import { TeamEditForm } from './TeamEditForm'; import { TeamManage } from './TeamManage'; import { TeamMembersDataTable } from './TeamMembersDataTable'; @@ -13,6 +15,7 @@ export function TeamSettings({ teamId }: { teamId: string }) { const team: any = useTeam(); const { user } = useLoginQuery(); const { pathname } = useNavigation(); + const { formatMessage } = useMessages(); const isAdmin = pathname.includes('/admin'); @@ -37,6 +40,10 @@ export function TeamSettings({ teamId }: { teamId: string }) { + + {formatMessage(labels.members)} + {isAdmin && } + {isTeamOwner && ( diff --git a/src/app/(main)/websites/WebsitesPage.tsx b/src/app/(main)/websites/WebsitesPage.tsx index 31de7047c..6f3548a97 100644 --- a/src/app/(main)/websites/WebsitesPage.tsx +++ b/src/app/(main)/websites/WebsitesPage.tsx @@ -3,22 +3,31 @@ import { Column } from '@umami/react-zen'; import { PageBody } from '@/components/common/PageBody'; import { PageHeader } from '@/components/common/PageHeader'; import { Panel } from '@/components/common/Panel'; -import { useMessages, useNavigation } from '@/components/hooks'; +import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; import { WebsiteAddButton } from './WebsiteAddButton'; import { WebsitesDataTable } from './WebsitesDataTable'; export function WebsitesPage() { + const { user } = useLoginQuery(); const { teamId } = useNavigation(); const { formatMessage, labels } = useMessages(); + const { data } = useTeamMembersQuery(teamId); + + const showActions = + (teamId && + data?.data.filter(team => team.userId === user.id && team.role !== ROLES.teamViewOnly) + .length > 0) || + (!teamId && user.role !== ROLES.viewOnly); return ( - + {showActions && } - + diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx index 28e33682a..fbad47491 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx @@ -1,6 +1,6 @@ -import { Button, Column, Grid, List, ListItem } from '@umami/react-zen'; +import { Button, Column, Grid, List, ListItem, ListSection } from '@umami/react-zen'; import { useState } from 'react'; -import { useFields, useMessages } from '@/components/hooks'; +import { type FieldGroup, useFields, useMessages } from '@/components/hooks'; export function FieldSelectForm({ selectedFields = [], @@ -13,7 +13,7 @@ export function FieldSelectForm({ }) { const [selected, setSelected] = useState(selectedFields); const { formatMessage, labels } = useMessages(); - const { fields } = useFields(); + const { fields, groupLabels } = useFields(); const handleChange = (value: string[]) => { setSelected(value); @@ -24,17 +24,38 @@ export function FieldSelectForm({ onClose(); }; + const groupedFields = fields + .filter(field => field.name !== 'event') + .reduce( + (acc, field) => { + const group = field.group; + if (!acc[group]) { + acc[group] = []; + } + acc[group].push(field); + return acc; + }, + {} as Record, + ); + return ( - - {fields.map(({ name, label }) => { - return ( - - {label} - - ); - })} - + + + {groupLabels.map(({ key: groupKey, label }) => { + const groupFields = groupedFields[groupKey]; + return ( + + {groupFields.map(field => ( + + {field.filterLabel} + + ))} + + ); + })} + + diff --git a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx index 6c91ba6dd..605ee3855 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx @@ -7,14 +7,18 @@ import { formatLongNumber, formatShortTime } from '@/lib/format'; export function WebsiteMetricsBar({ websiteId, + compareMode, }: { websiteId: string; showChange?: boolean; compareMode?: boolean; }) { - const { isAllTime } = useDateRange(); + const { isAllTime, dateCompare } = useDateRange(); const { formatMessage, labels, getErrorMessage } = useMessages(); - const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(websiteId); + const { data, isLoading, isFetching, error } = useWebsiteStatsQuery({ + websiteId, + compare: compareMode ? dateCompare?.compare : undefined, + }); const { pageviews, visitors, visits, bounces, totaltime, comparison } = data || {}; diff --git a/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx b/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx index 235d65822..e573da762 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx @@ -29,6 +29,8 @@ export function WebsiteNav({ event: undefined, compare: undefined, view: undefined, + unit: undefined, + excludeBounce: undefined, }); const items = [ diff --git a/src/app/(main)/websites/[websiteId]/WebsitePage.tsx b/src/app/(main)/websites/[websiteId]/WebsitePage.tsx index f587e1121..5412421e5 100644 --- a/src/app/(main)/websites/[websiteId]/WebsitePage.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsitePage.tsx @@ -1,7 +1,8 @@ 'use client'; -import { Column } from '@umami/react-zen'; +import { Column, Row } from '@umami/react-zen'; import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal'; import { Panel } from '@/components/common/Panel'; +import { UnitFilter } from '@/components/input/UnitFilter'; import { WebsiteChart } from './WebsiteChart'; import { WebsiteControls } from './WebsiteControls'; import { WebsiteMetricsBar } from './WebsiteMetricsBar'; @@ -10,9 +11,12 @@ import { WebsitePanels } from './WebsitePanels'; export function WebsitePage({ websiteId }: { websiteId: string }) { return ( - + + + + diff --git a/src/app/(main)/websites/[websiteId]/WebsitePanels.tsx b/src/app/(main)/websites/[websiteId]/WebsitePanels.tsx index a91d562e2..4a666f104 100644 --- a/src/app/(main)/websites/[websiteId]/WebsitePanels.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsitePanels.tsx @@ -1,15 +1,13 @@ import { Grid, Heading, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; import { GridRow } from '@/components/common/GridRow'; import { Panel } from '@/components/common/Panel'; -import { useMessages, useNavigation } from '@/components/hooks'; -import { EventsChart } from '@/components/metrics/EventsChart'; +import { useMessages } from '@/components/hooks'; import { MetricsTable } from '@/components/metrics/MetricsTable'; import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic'; import { WorldMap } from '@/components/metrics/WorldMap'; export function WebsitePanels({ websiteId }: { websiteId: string }) { const { formatMessage, labels } = useMessages(); - const { pathname } = useNavigation(); const tableProps = { websiteId, limit: 10, @@ -18,7 +16,6 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) { metric: formatMessage(labels.visitors), }; const rowProps = { minHeight: '570px' }; - const isSharePage = pathname.includes('/share/'); return ( @@ -116,25 +113,6 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) { - {isSharePage && ( - - - {formatMessage(labels.events)} - - - - - - - - )} ); } diff --git a/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx b/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx index bca8d2447..32d641b06 100644 --- a/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx +++ b/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx @@ -10,7 +10,7 @@ export function ComparePage({ websiteId }: { websiteId: string }) { return ( - + diff --git a/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx b/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx index 13c05160a..294b5fcc9 100644 --- a/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx +++ b/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx @@ -88,11 +88,41 @@ export function CompareTables({ websiteId }: { websiteId: string }) { label: formatMessage(labels.events), path: renderPath('event'), }, + { + id: 'utmSource', + label: formatMessage(labels.utmSource), + path: renderPath('utmSource'), + }, + { + id: 'utmMedium', + label: formatMessage(labels.utmMedium), + path: renderPath('utmMedium'), + }, + { + id: 'utmCampaign', + label: formatMessage(labels.utmCampaign), + path: renderPath('utmCampaign'), + }, + { + id: 'utmContent', + label: formatMessage(labels.utmContent), + path: renderPath('utmContent'), + }, + { + id: 'utmTerm', + label: formatMessage(labels.utmTerm), + path: renderPath('utmTerm'), + }, { id: 'hostname', label: formatMessage(labels.hostname), path: renderPath('hostname'), }, + { + id: 'distinctId', + label: formatMessage(labels.distinctId), + path: renderPath('distinctId'), + }, { id: 'tag', label: formatMessage(labels.tags), diff --git a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx index 55ec04037..f62d8a4c8 100644 --- a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx @@ -1,12 +1,18 @@ 'use client'; import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; -import { type Key, useState } from 'react'; +import locale from 'date-fns/locale/af'; +import { type Key, useMemo, 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 { useMessages } from '@/components/hooks'; +import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery'; 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'; @@ -15,16 +21,61 @@ const KEY_NAME = 'umami.events.tab'; export function EventsPage({ websiteId }) { const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart'); - const { formatMessage, labels } = useMessages(); + const { formatMessage, labels, getErrorMessage } = useMessages(); + const { data, isLoading, isFetching, error } = useEventStatsQuery({ + websiteId, + }); const handleSelect = (value: Key) => { setItem(KEY_NAME, value); setTab(value); }; + const metrics = useMemo(() => { + if (!data) return []; + + const { events, visitors, visits, uniqueEvents } = data || {}; + + return [ + { + value: visitors, + label: formatMessage(labels.visitors), + formatValue: formatLongNumber, + }, + { + value: visits, + label: formatMessage(labels.visits), + formatValue: formatLongNumber, + }, + { + value: events, + label: formatMessage(labels.events), + formatValue: formatLongNumber, + }, + { + value: uniqueEvents, + label: formatMessage(labels.uniqueEvents), + formatValue: formatLongNumber, + }, + ] as any; + }, [data, locale]); + return ( + + + {metrics?.map(({ label, value, formatValue }) => { + return ; + })} + + handleSelect(key)}> diff --git a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx index 261221bb6..a5eae12a6 100644 --- a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx +++ b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx @@ -25,6 +25,19 @@ export function EventsTable(props: DataTableProps) { const { updateParams } = useNavigation(); const { formatValue } = useFormat(); + const renderLink = (label: string, hostname: string) => { + return ( + + {label} + + ); + }; + return ( @@ -43,7 +56,7 @@ export function EventsTable(props: DataTableProps) { title={row.eventName || row.urlPath} truncate > - {row.eventName || row.urlPath} + {row.eventName || renderLink(row.urlPath, row.hostname)} {row.hasData > 0 && } diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx index 8dbb5e338..0bd5fb46f 100644 --- a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx @@ -75,8 +75,9 @@ export function RealtimeLog({ data }: { data: any }) { os: string; country: string; device: string; + hostname: string; }) => { - const { __type, eventName, urlPath, browser, os, country, device } = log; + const { __type, eventName, urlPath, browser, os, country, device, hostname } = log; if (__type === TYPE_EVENT) { return ( @@ -87,7 +88,8 @@ export function RealtimeLog({ data }: { data: any }) { url: ( @@ -101,7 +103,12 @@ export function RealtimeLog({ data }: { data: any }) { if (__type === TYPE_PAGEVIEW) { return ( - + {urlPath} ); diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx index cbb281083..df0ef834a 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx @@ -39,10 +39,23 @@ export function SessionActivity({ const { isMobile } = useMobile(); let lastDay = null; + const renderLink = (label: string, hostname: string) => { + return ( + + {label} + + ); + }; + return ( - {data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hasData }) => { + {data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hostname, hasData }) => { const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt)); lastDay = createdAt; @@ -61,7 +74,7 @@ export function SessionActivity({ : formatMessage(labels.viewedPage)} - {eventName || urlPath} + {eventName || renderLink(urlPath, hostname)} {hasData > 0 && } diff --git a/src/app/(main)/websites/[websiteId]/settings/ShareDeleteButton.tsx b/src/app/(main)/websites/[websiteId]/settings/ShareDeleteButton.tsx new file mode 100644 index 000000000..35e96df3f --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/ShareDeleteButton.tsx @@ -0,0 +1,57 @@ +import { ConfirmationForm } from '@/components/common/ConfirmationForm'; +import { useDeleteQuery, useMessages, useModified } from '@/components/hooks'; +import { Trash } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { messages } from '@/components/messages'; + +export function ShareDeleteButton({ + shareId, + slug, + onSave, +}: { + shareId: string; + slug: string; + onSave?: () => void; +}) { + const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages(); + const { mutateAsync, isPending, error } = useDeleteQuery(`/share/id/${shareId}`); + const { touch } = useModified(); + + const handleConfirm = async (close: () => void) => { + await mutateAsync(null, { + onSuccess: () => { + touch('shares'); + onSave?.(); + close(); + }, + }); + }; + + return ( + } + title={formatMessage(labels.confirm)} + variant="quiet" + width="400px" + > + {({ close }) => ( + {slug}, + }} + /> + } + isLoading={isPending} + error={getErrorMessage(error)} + onConfirm={handleConfirm.bind(null, close)} + onClose={close} + buttonLabel={formatMessage(labels.delete)} + buttonVariant="danger" + /> + )} + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/ShareEditButton.tsx b/src/app/(main)/websites/[websiteId]/settings/ShareEditButton.tsx new file mode 100644 index 000000000..df1c2e648 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/ShareEditButton.tsx @@ -0,0 +1,16 @@ +import { useMessages } from '@/components/hooks'; +import { Edit } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { ShareEditForm } from './ShareEditForm'; + +export function ShareEditButton({ shareId }: { shareId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + } title={formatMessage(labels.share)} variant="quiet" width="600px"> + {({ close }) => { + return ; + }} + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx b/src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx new file mode 100644 index 000000000..a71a49277 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx @@ -0,0 +1,171 @@ +import { + Button, + Checkbox, + Column, + Form, + FormField, + FormSubmitButton, + Grid, + Label, + Loading, + Row, + Text, + TextField, +} from '@umami/react-zen'; +import { useEffect, useState } from 'react'; +import { useApi, useConfig, useMessages, useModified } from '@/components/hooks'; +import { SHARE_NAV_ITEMS } from './constants'; + +export function ShareEditForm({ + shareId, + websiteId, + onSave, + onClose, +}: { + shareId?: string; + websiteId?: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { formatMessage, labels, getErrorMessage } = useMessages(); + const { cloudMode } = useConfig(); + const { get, post } = useApi(); + const { touch } = useModified(); + const { modified } = useModified('shares'); + const [share, setShare] = useState(null); + const [isLoading, setIsLoading] = useState(!!shareId); + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); + + const isEditing = !!shareId; + + const getUrl = (slug: string) => { + if (cloudMode) { + return `${process.env.cloudUrl}/share/${slug}`; + } + return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`; + }; + + useEffect(() => { + if (!shareId) return; + + const loadShare = async () => { + setIsLoading(true); + try { + const data = await get(`/share/id/${shareId}`); + setShare(data); + } finally { + setIsLoading(false); + } + }; + loadShare(); + }, [shareId, modified]); + + const handleSubmit = async (data: any) => { + const parameters: Record = {}; + SHARE_NAV_ITEMS.forEach(section => { + section.items.forEach(item => { + parameters[item.id] = data[item.id] ?? false; + }); + }); + + setIsPending(true); + setError(null); + + try { + if (isEditing) { + await post(`/share/id/${shareId}`, { + name: data.name, + slug: share.slug, + parameters, + }); + } else { + await post(`/websites/${websiteId}/shares`, { + name: data.name, + parameters, + }); + } + touch('shares'); + onSave?.(); + onClose?.(); + } catch (e) { + setError(e); + } finally { + setIsPending(false); + } + }; + + if (isLoading) { + return ; + } + + const url = isEditing ? getUrl(share?.slug || '') : null; + + // Build default values from share parameters + const defaultValues: Record = { + name: share?.name || '', + }; + SHARE_NAV_ITEMS.forEach(section => { + section.items.forEach(item => { + const defaultSelected = item.id === 'overview' || item.id === 'events'; + defaultValues[item.id] = share?.parameters?.[item.id] ?? defaultSelected; + }); + }); + + // Get all item ids for validation + const allItemIds = SHARE_NAV_ITEMS.flatMap(section => section.items.map(item => item.id)); + + return ( +
+ {({ watch }) => { + const values = watch(); + const hasSelection = allItemIds.some(id => values[id]); + + return ( + + {url && ( + + + + + )} + + + + + {SHARE_NAV_ITEMS.map(section => ( + + {formatMessage((labels as any)[section.section])} + + {section.items.map(item => ( + + {formatMessage((labels as any)[item.label])} + + ))} + + + ))} + + + {onClose && ( + + )} + + {formatMessage(labels.save)} + + + + ); + }} +
+ ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx b/src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx new file mode 100644 index 000000000..52f8a0579 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx @@ -0,0 +1,52 @@ +import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen'; +import { DateDistance } from '@/components/common/DateDistance'; +import { ExternalLink } from '@/components/common/ExternalLink'; +import { useConfig, useMessages, useMobile } from '@/components/hooks'; +import { ShareDeleteButton } from './ShareDeleteButton'; +import { ShareEditButton } from './ShareEditButton'; + +export function SharesTable(props: DataTableProps) { + const { formatMessage, labels } = useMessages(); + const { cloudMode } = useConfig(); + const { isMobile } = useMobile(); + + const getUrl = (slug: string) => { + if (cloudMode) { + return `${process.env.cloudUrl}/share/${slug}`; + } + return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`; + }; + + return ( + + + {({ name }: any) => name} + + + {({ slug }: any) => { + const url = getUrl(slug); + return ( + + {url} + + ); + }} + + {!isMobile && ( + + {(row: any) => } + + )} + + {({ id, slug }: any) => { + return ( + + + + + ); + }} + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx index 3970cdbdb..d39c45315 100644 --- a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx @@ -1,14 +1,11 @@ import { Column } from '@umami/react-zen'; import { Panel } from '@/components/common/Panel'; -import { useWebsite } from '@/components/hooks'; import { WebsiteData } from './WebsiteData'; import { WebsiteEditForm } from './WebsiteEditForm'; import { WebsiteShareForm } from './WebsiteShareForm'; import { WebsiteTrackingCode } from './WebsiteTrackingCode'; export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) { - const website = useWebsite(); - return ( @@ -18,7 +15,7 @@ export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal - + diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx index eddd21631..2ba9764d2 100644 --- a/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx @@ -1,93 +1,47 @@ -import { - Button, - Column, - Form, - FormButtons, - FormSubmitButton, - Label, - Row, - Switch, - TextField, -} from '@umami/react-zen'; -import { RefreshCcw } from 'lucide-react'; -import { useState } from 'react'; -import { IconLabel } from '@/components/common/IconLabel'; -import { useConfig, useMessages, useUpdateQuery } from '@/components/hooks'; -import { getRandomChars } from '@/lib/generate'; - -const generateId = () => getRandomChars(16); +import { Column, Heading, Row, Text } from '@umami/react-zen'; +import { Plus } from 'lucide-react'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages, useWebsiteSharesQuery } from '@/components/hooks'; +import { DialogButton } from '@/components/input/DialogButton'; +import { ShareEditForm } from './ShareEditForm'; +import { SharesTable } from './SharesTable'; export interface WebsiteShareFormProps { websiteId: string; - shareId?: string; - onSave?: () => void; - onClose?: () => void; } -export function WebsiteShareForm({ websiteId, shareId, onSave, onClose }: WebsiteShareFormProps) { - const { formatMessage, labels, messages, getErrorMessage } = useMessages(); - const [currentId, setCurrentId] = useState(shareId); - const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`); - const { cloudMode } = useConfig(); +export function WebsiteShareForm({ websiteId }: WebsiteShareFormProps) { + const { formatMessage, labels, messages } = useMessages(); + const { data, error, isLoading } = useWebsiteSharesQuery({ websiteId }); - const getUrl = (shareId: string) => { - if (cloudMode) { - return `${process.env.cloudUrl}/share/${shareId}`; - } - - return `${window?.location.origin}${process.env.basePath || ''}/share/${shareId}`; - }; - - const url = getUrl(currentId); - - const handleGenerate = () => { - setCurrentId(generateId()); - }; - - const handleSwitch = () => { - setCurrentId(currentId ? null : generateId()); - }; - - const handleSave = async () => { - const data = { - shareId: currentId, - }; - await mutateAsync(data, { - onSuccess: async () => { - toast(formatMessage(messages.saved)); - touch(`website:${websiteId}`); - onSave?.(); - onClose?.(); - }, - }); - }; + const shares = data?.data || []; + const hasShares = shares.length > 0; return ( -
- - - {formatMessage(labels.enableShareUrl)} - - {currentId && ( - - - - - - - - - + + + + {formatMessage(labels.share)} + } + label={formatMessage(labels.add)} + title={formatMessage(labels.share)} + variant="primary" + width="600px" + > + {({ close }) => } + + + {hasShares ? ( + <> + {formatMessage(messages.shareUrl)} + + + + ) : ( + {formatMessage(messages.noDataAvailable)} )} - - - {onClose && } - {formatMessage(labels.save)} - - - + ); } diff --git a/src/app/(main)/websites/[websiteId]/settings/constants.ts b/src/app/(main)/websites/[websiteId]/settings/constants.ts new file mode 100644 index 000000000..f4a3df80f --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/constants.ts @@ -0,0 +1,30 @@ +export const SHARE_NAV_ITEMS = [ + { + section: 'traffic', + items: [ + { id: 'overview', label: 'overview' }, + { id: 'events', label: 'events' }, + { id: 'sessions', label: 'sessions' }, + { id: 'realtime', label: 'realtime' }, + { id: 'compare', label: 'compare' }, + { id: 'breakdown', label: 'breakdown' }, + ], + }, + { + section: 'behavior', + items: [ + { id: 'goals', label: 'goals' }, + { id: 'funnels', label: 'funnels' }, + { id: 'journeys', label: 'journeys' }, + { id: 'retention', label: 'retention' }, + ], + }, + { + section: 'growth', + items: [ + { id: 'utm', label: 'utm' }, + { id: 'revenue', label: 'revenue' }, + { id: 'attribution', label: 'attribution' }, + ], + }, +]; diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 7bf0a8137..153f1f523 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,7 +1,14 @@ import redis from '@/lib/redis'; +import { parseRequest } from '@/lib/request'; import { ok } from '@/lib/response'; export async function POST(request: Request) { + const { error } = await parseRequest(request); + + if (error) { + return error(); + } + if (redis.enabled) { const token = request.headers.get('authorization')?.split(' ')?.[1]; diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts index bba3dde30..f82228695 100644 --- a/src/app/api/auth/sso/route.ts +++ b/src/app/api/auth/sso/route.ts @@ -1,7 +1,7 @@ import { saveAuth } from '@/lib/auth'; import redis from '@/lib/redis'; import { parseRequest } from '@/lib/request'; -import { json } from '@/lib/response'; +import { json, serverError } from '@/lib/response'; export async function POST(request: Request) { const { auth, error } = await parseRequest(request); @@ -10,9 +10,13 @@ export async function POST(request: Request) { return error(); } - if (redis.enabled) { - const token = await saveAuth({ userId: auth.user.id }, 86400); - - return json({ user: auth.user, token }); + if (!redis.enabled) { + return serverError({ + message: 'Redis is disabled', + }); } + + const token = await saveAuth({ userId: auth.user.id }, 86400); + + return json({ user: auth.user, token }); } diff --git a/src/app/api/reports/journey/route.ts b/src/app/api/reports/journey/route.ts index 29e853196..b53d225d4 100644 --- a/src/app/api/reports/journey/route.ts +++ b/src/app/api/reports/journey/route.ts @@ -12,11 +12,16 @@ export async function POST(request: Request) { } const { websiteId, parameters, filters } = body; + const { eventType } = parameters; if (!(await canViewWebsite(auth, websiteId))) { return unauthorized(); } + if (eventType) { + filters.eventType = eventType; + } + const queryFilters = await getQueryFilters(filters, websiteId); const data = await getJourney(websiteId, parameters, queryFilters); diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index a0becc2ac..c3aa9a00e 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -1,10 +1,10 @@ -import { startOfHour, startOfMonth } from 'date-fns'; +import { startOfHour } from 'date-fns'; import { isbot } from 'isbot'; import { serializeError } from 'serialize-error'; import { z } from 'zod'; import clickhouse from '@/lib/clickhouse'; import { COLLECTION_TYPE, EVENT_TYPE } from '@/lib/constants'; -import { hash, secret, uuid } from '@/lib/crypto'; +import { getSalt, hash, secret, uuid } from '@/lib/crypto'; import { getClientInfo, hasBlockedIp } from '@/lib/detect'; import { createToken, parseToken } from '@/lib/jwt'; import { fetchWebsite } from '@/lib/load'; @@ -130,7 +130,8 @@ export async function POST(request: Request) { const createdAt = timestamp ? new Date(timestamp * 1000) : new Date(); const now = Math.floor(Date.now() / 1000); - const sessionSalt = hash(startOfMonth(createdAt).toUTCString()); + const saltRotation = process.env.SALT_ROTATION || 'month'; + const sessionSalt = getSalt(saltRotation, createdAt); const visitSalt = hash(startOfHour(createdAt).toUTCString()); const sessionId = id ? uuid(sourceId, id) : uuid(sourceId, ip, userAgent, sessionSalt); diff --git a/src/app/api/share/[slug]/route.ts b/src/app/api/share/[slug]/route.ts index 678795e07..e7d5372fe 100644 --- a/src/app/api/share/[slug]/route.ts +++ b/src/app/api/share/[slug]/route.ts @@ -1,7 +1,47 @@ +import { ROLES } from '@/lib/constants'; import { secret } from '@/lib/crypto'; import { createToken } from '@/lib/jwt'; +import prisma from '@/lib/prisma'; +import redis from '@/lib/redis'; import { json, notFound } from '@/lib/response'; -import { getShareByCode } from '@/queries/prisma'; +import type { WhiteLabel } from '@/lib/types'; +import { getShareByCode, getWebsite } from '@/queries/prisma'; + +async function getAccountId(website: { userId?: string; teamId?: string }): Promise { + if (website.userId) { + return website.userId; + } + + if (website.teamId) { + const teamOwner = await prisma.client.teamUser.findFirst({ + where: { + teamId: website.teamId, + role: ROLES.teamOwner, + }, + select: { + userId: true, + }, + }); + + return teamOwner?.userId || null; + } + + return null; +} + +async function getWhiteLabel(accountId: string): Promise { + if (!redis.enabled) { + return null; + } + + const data = await redis.client.get(`white-label:${accountId}`); + + if (data) { + return data as WhiteLabel; + } + + return null; +} export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; @@ -12,8 +52,25 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu return notFound(); } - const data = { shareId: share.id }; - const token = createToken(data, secret()); + const website = await getWebsite(share.entityId); - return json({ ...data, token }); + const data: Record = { + shareId: share.id, + websiteId: share.entityId, + parameters: share.parameters, + }; + + data.token = createToken(data, secret()); + + const accountId = await getAccountId(website); + + if (accountId) { + const whiteLabel = await getWhiteLabel(accountId); + + if (whiteLabel) { + data.whiteLabel = whiteLabel; + } + } + + return json(data); } diff --git a/src/app/api/share/id/[shareId]/route.ts b/src/app/api/share/id/[shareId]/route.ts index da7dcf562..80da17b80 100644 --- a/src/app/api/share/id/[shareId]/route.ts +++ b/src/app/api/share/id/[shareId]/route.ts @@ -25,6 +25,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ shar export async function POST(request: Request, { params }: { params: Promise<{ shareId: string }> }) { const schema = z.object({ + name: z.string().max(200), slug: z.string().max(100), parameters: anyObjectParam, }); @@ -36,7 +37,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ sha } const { shareId } = await params; - const { slug, parameters } = body; + const { name, slug, parameters } = body; const share = await getShare(shareId); @@ -49,6 +50,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ sha } const result = await updateShare(shareId, { + name, slug, parameters, } as any); diff --git a/src/app/api/share/route.ts b/src/app/api/share/route.ts index 99f5df0ef..de8472fa5 100644 --- a/src/app/api/share/route.ts +++ b/src/app/api/share/route.ts @@ -1,5 +1,6 @@ import z from 'zod'; import { uuid } from '@/lib/crypto'; +import { getRandomChars } from '@/lib/generate'; import { parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { anyObjectParam } from '@/lib/schema'; @@ -10,7 +11,8 @@ export async function POST(request: Request) { const schema = z.object({ entityId: z.uuid(), shareType: z.coerce.number().int(), - slug: z.string().max(100), + name: z.string().max(200), + slug: z.string().max(100).optional(), parameters: anyObjectParam, }); @@ -20,7 +22,8 @@ export async function POST(request: Request) { return error(); } - const { entityId, shareType, slug, parameters } = body; + const { entityId, shareType, name, slug, parameters } = body; + const shareParameters = parameters ?? {}; if (!(await canUpdateEntity(auth, entityId))) { return unauthorized(); @@ -30,8 +33,9 @@ export async function POST(request: Request) { id: uuid(), entityId, shareType, - slug, - parameters, + name, + slug: slug || getRandomChars(16), + parameters: shareParameters, }); return json(share); diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts index 53ef59239..c571f4055 100644 --- a/src/app/api/teams/route.ts +++ b/src/app/api/teams/route.ts @@ -28,6 +28,7 @@ export async function GET(request: Request) { export async function POST(request: Request) { const schema = z.object({ name: z.string().max(50), + ownerId: z.uuid().optional(), }); const { auth, body, error } = await parseRequest(request, schema); @@ -40,7 +41,7 @@ export async function POST(request: Request) { return unauthorized(); } - const { name } = body; + const { name, ownerId } = body; const team = await createTeam( { @@ -48,7 +49,7 @@ export async function POST(request: Request) { name, accessCode: `team_${getRandomChars(16)}`, }, - auth.user.id, + ownerId ?? auth.user.id, ); return json(team); diff --git a/src/app/api/users/[userId]/route.ts b/src/app/api/users/[userId]/route.ts index aade8aa8d..e642fe3c1 100644 --- a/src/app/api/users/[userId]/route.ts +++ b/src/app/api/users/[userId]/route.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { hashPassword } from '@/lib/password'; import { parseRequest } from '@/lib/request'; -import { badRequest, json, ok, unauthorized } from '@/lib/response'; +import { badRequest, json, notFound, ok, unauthorized } from '@/lib/response'; import { userRoleParam } from '@/lib/schema'; import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions'; import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma'; @@ -27,7 +27,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ user export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) { const schema = z.object({ username: z.string().max(255).optional(), - password: z.string().max(255).optional(), + password: z.string().min(8).max(255).optional(), role: userRoleParam.optional(), }); @@ -47,6 +47,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ use const user = await getUser(userId); + if (!user) { + return notFound(); + } + const data: any = {}; if (password) { diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index dbb114cf7..4335c33fb 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -4,6 +4,7 @@ import { uuid } from '@/lib/crypto'; import { hashPassword } from '@/lib/password'; import { parseRequest } from '@/lib/request'; import { badRequest, json, unauthorized } from '@/lib/response'; +import { userRoleParam } from '@/lib/schema'; import { canCreateUser } from '@/permissions'; import { createUser, getUserByUsername } from '@/queries/prisma'; @@ -11,8 +12,8 @@ export async function POST(request: Request) { const schema = z.object({ id: z.uuid().optional(), username: z.string().max(255), - password: z.string(), - role: z.string().regex(/admin|user|view-only/i), + password: z.string().min(8).max(255), + role: userRoleParam, }); const { auth, body, error } = await parseRequest(request, schema); diff --git a/src/app/api/websites/[websiteId]/events/stats/route.ts b/src/app/api/websites/[websiteId]/events/stats/route.ts new file mode 100644 index 000000000..61e151d4a --- /dev/null +++ b/src/app/api/websites/[websiteId]/events/stats/route.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { dateRangeParams, filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getWebsiteEventStats } from '@/queries/sql/events/getWebsiteEventStats'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + ...dateRangeParams, + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getWebsiteEventStats(websiteId, filters); + + return json({ data }); +} diff --git a/src/app/api/websites/[websiteId]/route.ts b/src/app/api/websites/[websiteId]/route.ts index b4c0e7e88..1443a541b 100644 --- a/src/app/api/websites/[websiteId]/route.ts +++ b/src/app/api/websites/[websiteId]/route.ts @@ -1,9 +1,17 @@ import { z } from 'zod'; -import { SHARE_ID_REGEX } from '@/lib/constants'; +import { ENTITY_TYPE } from '@/lib/constants'; +import { uuid } from '@/lib/crypto'; import { parseRequest } from '@/lib/request'; import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response'; import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions'; -import { deleteWebsite, getWebsite, updateWebsite } from '@/queries/prisma'; +import { + createShare, + deleteSharesByEntityId, + deleteWebsite, + getShareByEntityId, + getWebsite, + updateWebsite, +} from '@/queries/prisma'; export async function GET( request: Request, @@ -33,7 +41,7 @@ export async function POST( const schema = z.object({ name: z.string().optional(), domain: z.string().optional(), - shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(), + shareId: z.string().max(50).nullable().optional(), }); const { auth, body, error } = await parseRequest(request, schema); @@ -50,11 +58,29 @@ export async function POST( } try { - const website = await updateWebsite(websiteId, { name, domain, shareId }); + const website = await updateWebsite(websiteId, { name, domain }); - return Response.json(website); + if (shareId === null) { + await deleteSharesByEntityId(website.id); + } + + const share = shareId + ? await createShare({ + id: uuid(), + entityId: websiteId, + shareType: ENTITY_TYPE.website, + name: website.name, + slug: shareId, + parameters: { overview: true, events: true }, + }) + : await getShareByEntityId(websiteId); + + return json({ + ...website, + shareId: share?.slug ?? null, + }); } catch (e: any) { - if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('share_id')) { + if (e.message.toLowerCase().includes('unique constraint')) { return badRequest({ message: 'That share ID is already taken.' }); } diff --git a/src/app/api/websites/[websiteId]/segments/route.ts b/src/app/api/websites/[websiteId]/segments/route.ts index 45927656c..db34193ed 100644 --- a/src/app/api/websites/[websiteId]/segments/route.ts +++ b/src/app/api/websites/[websiteId]/segments/route.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { uuid } from '@/lib/crypto'; import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; -import { anyObjectParam, searchParams, segmentTypeParam } from '@/lib/schema'; +import { searchParams, segmentParamSchema, segmentTypeParam } from '@/lib/schema'; import { canUpdateWebsite, canViewWebsite } from '@/permissions'; import { createSegment, getWebsiteSegments } from '@/queries/prisma'; @@ -42,7 +42,7 @@ export async function POST( const schema = z.object({ type: segmentTypeParam, name: z.string().max(200), - parameters: anyObjectParam, + parameters: segmentParamSchema, }); const { auth, body, error } = await parseRequest(request, schema); diff --git a/src/app/api/websites/[websiteId]/shares/route.ts b/src/app/api/websites/[websiteId]/shares/route.ts new file mode 100644 index 000000000..65d53771d --- /dev/null +++ b/src/app/api/websites/[websiteId]/shares/route.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; +import { ENTITY_TYPE } from '@/lib/constants'; +import { uuid } from '@/lib/crypto'; +import { getRandomChars } from '@/lib/generate'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { anyObjectParam, filterParams, pagingParams } from '@/lib/schema'; +import { canUpdateWebsite, canViewWebsite } from '@/permissions'; +import { createShare, getSharesByEntityId } from '@/queries/prisma'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + ...filterParams, + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { page, pageSize, search } = query; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getSharesByEntityId(websiteId, { + page, + pageSize, + search, + }); + + return json(data); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + name: z.string().max(200), + parameters: anyObjectParam.optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { name, parameters } = body; + const shareParameters = parameters ?? {}; + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + const slug = getRandomChars(16); + + const share = await createShare({ + id: uuid(), + entityId: websiteId, + shareType: ENTITY_TYPE.website, + name, + slug, + parameters: shareParameters, + }); + + return json(share); +} diff --git a/src/app/api/websites/[websiteId]/stats/route.ts b/src/app/api/websites/[websiteId]/stats/route.ts index b7177b5d8..9d21f4f55 100644 --- a/src/app/api/websites/[websiteId]/stats/route.ts +++ b/src/app/api/websites/[websiteId]/stats/route.ts @@ -31,9 +31,11 @@ export async function GET( const data = await getWebsiteStats(websiteId, filters); - const compare = filters.compare ?? 'prev'; - - const { startDate, endDate } = getCompareDate(compare, filters.startDate, filters.endDate); + const { startDate, endDate } = getCompareDate( + filters.compare ?? 'prev', + filters.startDate, + filters.endDate, + ); const comparison = await getWebsiteStats(websiteId, { ...filters, diff --git a/src/app/api/websites/route.ts b/src/app/api/websites/route.ts index dd8e0ffdd..d54aeac62 100644 --- a/src/app/api/websites/route.ts +++ b/src/app/api/websites/route.ts @@ -1,11 +1,12 @@ import { z } from 'zod'; +import { ENTITY_TYPE } from '@/lib/constants'; import { uuid } from '@/lib/crypto'; import { fetchAccount } from '@/lib/load'; import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { pagingParams, searchParams } from '@/lib/schema'; import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions'; -import { createWebsite, getWebsiteCount } from '@/queries/prisma'; +import { createShare, createWebsite, getWebsiteCount } from '@/queries/prisma'; import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma/website'; const CLOUD_WEBSITE_LIMIT = 3; @@ -72,7 +73,6 @@ export async function POST(request: Request) { createdBy: auth.user.id, name, domain, - shareId, teamId, }; @@ -82,5 +82,19 @@ export async function POST(request: Request) { const website = await createWebsite(data); - return json(website); + const share = shareId + ? await createShare({ + id: uuid(), + entityId: website.id, + shareType: ENTITY_TYPE.website, + name: website.name, + slug: shareId, + parameters: { overview: true, events: true }, + }) + : null; + + return json({ + ...website, + shareId: share?.slug ?? null, + }); } diff --git a/src/app/share/ShareProvider.tsx b/src/app/share/ShareProvider.tsx new file mode 100644 index 000000000..b83d37949 --- /dev/null +++ b/src/app/share/ShareProvider.tsx @@ -0,0 +1,77 @@ +'use client'; +import { Loading } from '@umami/react-zen'; +import { usePathname, useRouter } from 'next/navigation'; +import { createContext, type ReactNode, useEffect } from 'react'; +import { useShareTokenQuery } from '@/components/hooks'; +import type { WhiteLabel } from '@/lib/types'; + +export interface ShareData { + shareId: string; + slug: string; + websiteId: string; + parameters: any; + token: string; + whiteLabel?: WhiteLabel; +} + +export const ShareContext = createContext(null); + +const ALL_SECTION_IDS = [ + 'overview', + 'events', + 'sessions', + 'realtime', + 'compare', + 'breakdown', + 'goals', + 'funnels', + 'journeys', + 'retention', + 'utm', + 'revenue', + 'attribution', +]; + +function getSharePath(pathname: string) { + const segments = pathname.split('/'); + const firstSegment = segments[3]; + + // If first segment looks like a domain name, skip it + if (firstSegment?.includes('.')) { + return segments[4]; + } + + return firstSegment; +} + +export function ShareProvider({ slug, children }: { slug: string; children: ReactNode }) { + const { share, isLoading, isFetching } = useShareTokenQuery(slug); + const router = useRouter(); + const pathname = usePathname(); + const path = getSharePath(pathname); + + const allowedSections = share?.parameters + ? ALL_SECTION_IDS.filter(id => share.parameters[id] !== false) + : []; + + const shouldRedirect = + allowedSections.length === 1 && + allowedSections[0] !== 'overview' && + (path === undefined || path === '' || path === 'overview'); + + useEffect(() => { + if (shouldRedirect) { + router.replace(`/share/${slug}/${allowedSections[0]}`); + } + }, [shouldRedirect, slug, allowedSections, router]); + + if (isFetching && isLoading) { + return ; + } + + if (!share || shouldRedirect) { + return null; + } + + return {children}; +} diff --git a/src/app/share/[...shareId]/Footer.tsx b/src/app/share/[...shareId]/Footer.tsx deleted file mode 100644 index f29486286..000000000 --- a/src/app/share/[...shareId]/Footer.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Row, Text } from '@umami/react-zen'; -import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants'; - -export function Footer() { - return ( - - - umami {`v${CURRENT_VERSION}`} - - - ); -} diff --git a/src/app/share/[...shareId]/Header.tsx b/src/app/share/[...shareId]/Header.tsx deleted file mode 100644 index d7b7dcb42..000000000 --- a/src/app/share/[...shareId]/Header.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Icon, Row, Text, ThemeButton } from '@umami/react-zen'; -import { LanguageButton } from '@/components/input/LanguageButton'; -import { PreferencesButton } from '@/components/input/PreferencesButton'; -import { Logo } from '@/components/svg'; - -export function Header() { - return ( - - - - - - - umami - - - - - - - - - ); -} diff --git a/src/app/share/[...shareId]/SharePage.tsx b/src/app/share/[...shareId]/SharePage.tsx deleted file mode 100644 index 7ed066735..000000000 --- a/src/app/share/[...shareId]/SharePage.tsx +++ /dev/null @@ -1,41 +0,0 @@ -'use client'; -import { Column, useTheme } from '@umami/react-zen'; -import { useEffect } from 'react'; -import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader'; -import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage'; -import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider'; -import { PageBody } from '@/components/common/PageBody'; -import { useShareTokenQuery } from '@/components/hooks'; -import { Footer } from './Footer'; -import { Header } from './Header'; - -export function SharePage({ shareId }) { - const { shareToken, isLoading } = useShareTokenQuery(shareId); - const { setTheme } = useTheme(); - - useEffect(() => { - const url = new URL(window?.location?.href); - const theme = url.searchParams.get('theme'); - - if (theme === 'light' || theme === 'dark') { - setTheme(theme); - } - }, []); - - if (isLoading || !shareToken) { - return null; - } - - return ( - - -
- - - - -