Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
32a6399
feat(db): add WebsiteGoogleAuth model to prisma schema
boutterudy Mar 29, 2026
5bcd7c4
feat(db): add migration for website_google_auth table
boutterudy Mar 29, 2026
1d20b7c
feat(constants): add `GOOGLE_DOMAINS` and `GOOGLE_DOMAIN_TO_COUNTRY`
boutterudy Mar 29, 2026
463563c
feat(lib): add Google OAuth and Search Console API client
boutterudy Mar 29, 2026
cc157c8
feat(queries): add Prisma queries for Google auth
boutterudy Mar 29, 2026
677b67a
feat(api): add Google OAuth callback and Google Search Console routes
boutterudy Mar 29, 2026
b59431e
feat(hooks): add `useWebsiteGoogleAuthQuery` and `useWebsiteSearchTer…
boutterudy Mar 29, 2026
270450c
feat(i18n): add labels and messages for Google Search Console integra…
boutterudy Mar 29, 2026
05ffc44
feat(ui): add optional `metricToolTip` prop to `ListTable`
boutterudy Mar 29, 2026
6f17b35
feat(hooks): add `useWebsiteGscPropertiesQuery` and `useWebsiteGscPro…
boutterudy Mar 29, 2026
e38badf
feat(ui): add Google Search Console settings section
boutterudy Mar 29, 2026
3787e2f
fix(ui): display of properties in `Select`
boutterudy Mar 29, 2026
fab4d5c
feat(constants): add `COUNTRY_ALPHA2_TO_ALPHA3`
boutterudy Mar 29, 2026
a6f0aca
feat(ui): add search terms panel and expanded view to website dashboard
boutterudy Mar 29, 2026
a62fc3f
fix(lint): format
boutterudy Mar 29, 2026
6d72700
feat(api): strongly type country param in search-terms route
boutterudy Mar 29, 2026
377a2ea
fix: after code review
boutterudy Mar 30, 2026
41b97e3
fix: remove total from `search-terms` API
boutterudy Mar 31, 2026
a362511
fix: move `MetricColumnLabel` and `MetricColumn` to the root of `List…
boutterudy Mar 31, 2026
f88d5e8
fix: props types
boutterudy Mar 31, 2026
374b271
fix: `propertyUrl` max length (500)
boutterudy Mar 31, 2026
307f848
fix: handle errors in `handleConnectGoogle`
boutterudy Mar 31, 2026
4073b2c
fix: remove `total` from `WebsiteSearchTermsData`
boutterudy Mar 31, 2026
9ead4a4
fix: handle refresh token rotation
boutterudy Mar 31, 2026
ad04f1f
fix: only fire `useWebsiteSearchTermsQuery` when Google account is li…
boutterudy Mar 31, 2026
c24a595
fix: improve error handling on delete
boutterudy Mar 31, 2026
a8781fb
fix: reset `view` when `searchTerms` is selected but no valid Google …
boutterudy Apr 1, 2026
fb37d92
fix: update `sourcesTab` on `googleDomain` change
boutterudy Apr 1, 2026
a3b89c9
refactor: create and use `useGoogleDomain`
boutterudy Apr 1, 2026
ba40a08
fix: display of `WebsiteSearchTermsExpandedView`
boutterudy Apr 1, 2026
d29a144
chore: add comment explaining `contains` operator for GSC page filter
boutterudy Apr 1, 2026
306cc06
fix: `basePath` value
boutterudy Apr 1, 2026
a5d76e0
fix: add Google Search Console variables to `.env.sample`
boutterudy Apr 1, 2026
22a8d87
fix: catch error in `handlePropertyChange`
boutterudy Apr 1, 2026
b5112a4
fix: add previously selected tab fallback
boutterudy Apr 1, 2026
a2f3cc4
fix: memoize `downloadData`
boutterudy Apr 1, 2026
09846cf
chore: add missing translations
boutterudy Apr 1, 2026
42310d3
fix: after code review
boutterudy Apr 1, 2026
ffbc119
fix: reset error at the beginning of `handleConnectGoogle`
boutterudy Apr 1, 2026
3d2f8c3
fix: after code review
boutterudy Apr 1, 2026
2a151f1
fix: regenerate prisma migration
boutterudy Apr 1, 2026
b4ad940
fix: import type
boutterudy Apr 1, 2026
3c1fddb
fix: use `LinkButton` instead of `<a>`
boutterudy Apr 1, 2026
94f12eb
fix: ensure there is a `refreshToken` before using it
boutterudy Apr 1, 2026
f6b5b55
fix: remove useless `totalClicks` and `percent` from `filteredRows`
boutterudy Apr 1, 2026
2c4670c
fix: reset `gsc_error` param at the start of `handleConnectGoogle`
boutterudy Apr 1, 2026
ddccd6e
fix: return accurate value of `connected`
boutterudy Apr 1, 2026
b1f7fce
Use non local host url and use config in Dockerfile
nesha7 Apr 7, 2026
6c5efb2
Merge branch 'dev' into feat-integration-google-search-console
boutterudy Apr 22, 2026
d8990bc
fix: missing imports
boutterudy Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions podman/env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ APP_SECRET=replace-me-with-a-random-string
POSTGRES_DB=umami
POSTGRES_USER=umami
POSTGRES_PASSWORD=replace-me-with-a-random-string

# Google Search Console integration (OAuth Client credentials)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
17 changes: 17 additions & 0 deletions prisma/migrations/20_add_google_auth/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "website_google_auth" (
"google_auth_id" UUID NOT NULL,
"website_id" UUID NOT NULL,
"access_token" TEXT NOT NULL,
"refresh_token" TEXT NOT NULL,
"expires_at" TIMESTAMPTZ(6) NOT NULL,
"email" VARCHAR(255) NOT NULL,
"property_url" VARCHAR(500),
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,

CONSTRAINT "website_google_auth_pkey" PRIMARY KEY ("google_auth_id")
);

-- CreateIndex
CREATE UNIQUE INDEX "website_google_auth_website_id_key" ON "website_google_auth"("website_id");
17 changes: 17 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ model Website {
sessionData SessionData[]
sessionReplays SessionReplay[]
sessionReplaysSaved SessionReplaySaved[]
googleAuth WebsiteGoogleAuth?

@@index([userId])
@@index([teamId])
Expand All @@ -96,6 +97,22 @@ model Website {
@@map("website")
}

model WebsiteGoogleAuth {
id String @id @default(uuid()) @map("google_auth_id") @db.Uuid
websiteId String @unique @map("website_id") @db.Uuid
accessToken String @map("access_token") @db.Text
refreshToken String @map("refresh_token") @db.Text
expiresAt DateTime @map("expires_at") @db.Timestamptz(6)
email String @db.VarChar(255)
propertyUrl String? @map("property_url") @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)

website Website @relation(fields: [websiteId], references: [id], onDelete: Cascade)

@@map("website_google_auth")
}

model WebsiteEvent {
id String @id() @map("event_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
Expand Down
20 changes: 19 additions & 1 deletion public/intl/messages/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"day": "Day",
"default-date-range": "Default date range",
"delete": "Delete",
"disconnect": "Disconnect",
"delete-report": "Delete report",
"delete-team": "Delete team",
"delete-user": "Delete user",
Expand Down Expand Up @@ -351,6 +352,15 @@
"untitled": "Untitled",
"upgrade": "Upgrade",
"update": "Update",
"google-search-console": "Google Search Console",
"google-account": "Google account",
"search-terms": "Search terms",
"connect-with-google": "Continue with Google",
"disconnect-google-account": "Disconnect Google account",
"select-gsc-property": "Select a property",
"impressions": "Impressions",
"ctr": "CTR",
"position": "Position",
"url": "URL",
"user": "User",
"username": "Username",
Expand Down Expand Up @@ -429,6 +439,14 @@
"user-deleted": "User deleted.",
"viewed-page": "Viewed page",
"upgrade-required": "This feature requires a {plan} plan subscription.",
"visitor-log": "Visitor from <b>{country}</b> using <b>{browser}</b> on <b>{os}</b> <b>{device}</b>"
"visitor-log": "Visitor from <b>{country}</b> using <b>{browser}</b> on <b>{os}</b> <b>{device}</b>",
"gsc-not-configured-prompt": "Connect your Google Search Console account to view search terms.",
"gsc-property-instruction": "Select the Google Search Console property you would like to pull keyword data from.",
"gsc-verification-note": "Note: You also need to set up your site on Google Search Console for this integration to work.",
"gsc-connect-error": "Failed to connect Google account. Please try again.",
"gsc-save-property-error": "Failed to save the selected property. Please try again.",
"gsc-description": "You can integrate Google Search Console to retrieve all search results statistics, including the search terms used by users who visit your site.",
"gsc-account-description": "Link the Google account that manages your website's Google Search Console.",
"visitors-gsc-tooltip": "Visitors represents the number of clicks from Google Search results."
}
}
26 changes: 24 additions & 2 deletions src/app/(main)/websites/[websiteId]/ExpandedViewModal.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import { Dialog, Modal } from '@umami/react-zen';
import { useEffect } from 'react';
import { WebsiteExpandedView } from '@/app/(main)/websites/[websiteId]/WebsiteExpandedView';
import { useMobile, useNavigation } from '@/components/hooks';
import { WebsiteSearchTermsExpandedView } from '@/app/(main)/websites/[websiteId]/WebsiteSearchTermsExpandedView';
import { useGoogleDomain, useMobile, useNavigation } from '@/components/hooks';

export function ExpandedViewModal({
websiteId,
excludedIds,
}: {
websiteId: string;
excludedIds?: string[];
excludedIds?: Array<string>;
}) {
const {
router,
query: { view },
updateParams,
} = useNavigation();
const { isMobile } = useMobile();
const googleDomain = useGoogleDomain();

const handleClose = (close: () => void) => {
router.push(updateParams({ view: undefined }));
Expand All @@ -27,6 +30,16 @@ export function ExpandedViewModal({
}
};

useEffect(() => {
if (view === 'searchTerms' && !googleDomain) {
router.replace(updateParams({ view: undefined }));
}
}, [view, googleDomain, router, updateParams]);

if (view === 'searchTerms' && !googleDomain) {
return null;
}

return (
<Modal isOpen={!!view} onOpenChange={handleOpenChange} isDismissable>
<Dialog
Expand All @@ -38,6 +51,15 @@ export function ExpandedViewModal({
}}
>
{({ close }) => {
if (view === 'searchTerms' && googleDomain) {
return (
<WebsiteSearchTermsExpandedView
websiteId={websiteId}
googleDomain={googleDomain}
onClose={() => handleClose(close)}
/>
);
}
return (
<WebsiteExpandedView
websiteId={websiteId}
Expand Down
26 changes: 24 additions & 2 deletions src/app/(main)/websites/[websiteId]/WebsitePanels.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Grid, Heading, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import { useEffect, useState } from 'react';
import { GridRow } from '@/components/common/GridRow';
import { Panel } from '@/components/common/Panel';
import { useMessages, useMobile } from '@/components/hooks';
import { useGoogleDomain, useMessages, useMobile } from '@/components/hooks';
import { MetricsTable } from '@/components/metrics/MetricsTable';
import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic';
import { WorldMap } from '@/components/metrics/WorldMap';
import { WebsiteSearchTerms } from './WebsiteSearchTerms';

export function WebsitePanels({ websiteId }: { websiteId: string }) {
const { t, labels } = useMessages();
Expand All @@ -18,6 +20,20 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
const rowProps = { minHeight: '570px' };
const { isMobile } = useMobile();

const googleDomain = useGoogleDomain();

const [sourcesTab, setSourcesTab] = useState<string | number>(
googleDomain ? 'searchTerms' : 'referrer',
);

useEffect(() => {
if (googleDomain) {
setSourcesTab('searchTerms');
} else {
setSourcesTab(prev => (prev === 'searchTerms' ? 'referrer' : prev));
}
}, [googleDomain]);

return (
<Grid gap="3">
<GridRow layout="two" {...rowProps}>
Expand All @@ -42,11 +58,17 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
</Panel>
<Panel>
<Heading size="2xl">{t(labels.sources)}</Heading>
<Tabs>
<Tabs selectedKey={sourcesTab} onSelectionChange={key => setSourcesTab(key)}>
<TabList>
{googleDomain && <Tab id="searchTerms">{t(labels.searchTerms)}</Tab>}
<Tab id="referrer">{t(labels.referrers)}</Tab>
<Tab id="channel">{t(labels.channels)}</Tab>
</TabList>
{googleDomain && (
<TabPanel id="searchTerms">
<WebsiteSearchTerms websiteId={websiteId} googleDomain={googleDomain} />
</TabPanel>
)}
<TabPanel id="referrer">
<MetricsTable type="referrer" title={t(labels.referrer)} {...tableProps} />
</TabPanel>
Expand Down
106 changes: 106 additions & 0 deletions src/app/(main)/websites/[websiteId]/WebsiteSearchTerms.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
'use client';

import { Grid, Row, Text } from '@umami/react-zen';
import { useMemo } from 'react';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { LinkButton } from '@/components/common/LinkButton';
import {
useMessages,
useNavigation,
useWebsiteGoogleAuthQuery,
useWebsiteSearchTermsFilters,
useWebsiteSearchTermsQuery,
} from '@/components/hooks';
import { IconLabel } from '@/components/common/IconLabel';
import { Maximize } from '@/components/icons';
import { ListTable } from '@/components/metrics/ListTable';
import type { GoogleDomain } from '@/lib/constants';

interface Props {
websiteId: string;
googleDomain: GoogleDomain;
}

export function WebsiteSearchTerms({ websiteId, googleDomain }: Props) {
const { t, labels, messages } = useMessages();
const { updateParams } = useNavigation();
const { path, country } = useWebsiteSearchTermsFilters();

const { data: authData, isLoading: authLoading } = useWebsiteGoogleAuthQuery(websiteId);

const { data, isLoading, isFetching, error } = useWebsiteSearchTermsQuery(websiteId, {
path,
googleDomain,
country,
limit: 10,
offset: 0,
enabled: !!authData?.connected && !!authData?.propertyUrl,
});

const basePath = process.env.basePath || '';

const tableData = useMemo(() => {
if (!data?.rows?.length) return [];

const totalClicks = data.rows.reduce((sum, r) => sum + r.clicks, 0);

return data.rows.map(row => ({
label: row.query,
count: row.clicks,
percent: totalClicks > 0 ? (row.clicks / totalClicks) * 100 : 0,
}));
}, [data]);

// If GSC not connected, show prompt
if (!authLoading && !authData?.connected) {
return (
<Row alignItems="center" justifyContent="center" padding="8">
<Text color="muted" size="sm" align="center">
{t(messages.gscNotConfiguredPrompt)}{' '}
<LinkButton href={`${basePath}/websites/${websiteId}/settings`}>
{t(messages.goToSettings)}
</LinkButton>
</Text>
</Row>
);
}

if (!authLoading && authData?.connected && !authData?.propertyUrl) {
return (
<Row alignItems="center" justifyContent="center" padding="8">
<Text color="muted" size="sm" align="center">
{t(messages.gscPropertyInstruction)}{' '}
<LinkButton href={`${basePath}/websites/${websiteId}/settings`}>
{t(messages.goToSettings)}
</LinkButton>
</Text>
</Row>
);
}

return (
<LoadingPanel
data={data}
isFetching={isFetching}
isLoading={isLoading || authLoading}
error={error}
minHeight="400px"
>
<Grid padding="2">
{data && (
<ListTable
data={tableData}
title={t(labels.searchTerms)}
metric={t(labels.visitors)}
metricToolTip={t(messages.visitorsGscTooltip)}
/>
)}
<Row justifyContent="center" alignItems="flex-end" paddingTop="4">
<LinkButton href={updateParams({ view: 'searchTerms' })} variant="quiet">
<IconLabel icon={<Maximize />}>{t(labels.more)}</IconLabel>
</LinkButton>
</Row>
</Grid>
</LoadingPanel>
);
}
Loading