Compare commits

...

23 commits

Author SHA1 Message Date
Francis Cao
c5aa8be15c remove missing imports
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-28 00:06:47 -08:00
Francis Cao
daccd22ab2 remove duplicate logos and names on sharepage 2026-01-27 23:58:31 -08:00
Francis Cao
b958403224 add mobile navbar to share page 2026-01-27 23:54:43 -08:00
Francis Cao
752f395d83 revert websitelayout changes 2026-01-27 22:47:29 -08:00
Francis Cao
c7a0d65590 add websiteheader to share page. Fix overview WebsiteNav Bug 2026-01-27 22:17:49 -08:00
Mike Cao
c3dad5b7ef Merge remote-tracking branch 'origin/dev' into dev 2026-01-27 18:51:05 -08:00
Mike Cao
9426de90f7 Fixed share path. 2026-01-27 18:50:53 -08:00
Francis Cao
c527819fd4 fix minute label formatting. Closes #3088 2026-01-27 18:22:39 -08:00
Mike Cao
f7bdd5c54e Merge remote-tracking branch 'origin/dev' into dev 2026-01-27 18:05:54 -08:00
Mike Cao
f32ab11785 Fixed bad reference. 2026-01-27 18:05:04 -08:00
Francis Cao
dde1c3a57a Increase website select pageSize to 20. Closes #3913 2026-01-27 17:07:14 -08:00
Mike Cao
4680c89e28 Merge branch 'dev' of https://github.com/umami-software/umami into dev 2026-01-26 11:18:34 -08:00
Mike Cao
fdafe13c35 Block share token from all editing permissions.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 19:13:49 -08:00
Mike Cao
e782c2e627 Block share token users from modifying reports via API.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:06:39 -08:00
Mike Cao
801a3ec6bb Hide add/edit buttons on share pages.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 14:51:46 -08:00
Mike Cao
4a09f2bff6 Share page changes. 2026-01-24 02:47:09 -08:00
Mike Cao
c9f6653b62 Redirect to single allowed section on share page. 2026-01-24 02:21:51 -08:00
Mike Cao
af7f7adf5b Add name field to share feature and require at least one item selection.
- Add name field to ShareCreateForm and ShareEditForm
- Add name column to SharesTable
- Update API routes to handle name field
- Require at least one item to be selected before saving
- Display parameters in responsive 3-column grid in edit form

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 20:17:45 -08:00
Mike Cao
4c9b2f10da Merge branch 'dev' of https://github.com/umami-software/umami into dev 2026-01-22 19:17:22 -08:00
Mike Cao
adea3e9b1c Merge branch 'analytics' into dev
# Conflicts:
#	.gitignore
#	src/app/api/share/[slug]/route.ts
#	src/app/share/[...shareId]/SharePage.tsx
2026-01-22 17:44:45 -08:00
Mike Cao
518f0b66c6 Don't render link on share page.
Some checks failed
Create docker images (cloud) / Build, push, and deploy (push) Has been cancelled
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
2026-01-22 03:02:15 -08:00
Mike Cao
f84e67b0e6 White-label support. 2026-01-22 01:50:24 -08:00
Francis Cao
52d9dd2871 pass unit into revenue report
Some checks failed
Create docker images (cloud) / Build, push, and deploy (push) Has been cancelled
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
2026-01-20 19:11:00 -08:00
30 changed files with 503 additions and 285 deletions

94
.gitignore vendored
View file

@ -1,46 +1,48 @@
# 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
# 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
nul
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.*
*.env.*
*.dev.yml

View file

@ -11,7 +11,7 @@
},
"type": "module",
"scripts": {
"dev": "next dev -p 3001 --turbo",
"dev": "next dev -p 3002 --turbo",
"build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app",
"start": "next start",
"build-docker": "npm-run-all build-db build-tracker build-geo build-app",

View file

@ -1,6 +1,6 @@
import { Box, Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useMessages, useResultQuery } from '@/components/hooks';
import { useMessages, useNavigation, useResultQuery } from '@/components/hooks';
import { File, User } from '@/components/icons';
import { ReportEditButton } from '@/components/input/ReportEditButton';
import { ChangeLabel } from '@/components/metrics/ChangeLabel';
@ -20,6 +20,8 @@ type FunnelResult = {
export function Funnel({ id, name, type, parameters, websiteId }) {
const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
const { data, error, isLoading } = useResultQuery(type, {
websiteId,
...parameters,
@ -36,21 +38,22 @@ export function Funnel({ id, name, type, parameters, websiteId }) {
</Text>
</Row>
</Column>
<Column>
<ReportEditButton id={id} name={name} type={type}>
{({ close }) => {
return (
<Dialog
title={formatMessage(labels.funnel)}
variant="modal"
style={{ minHeight: 300, minWidth: 400 }}
>
<FunnelEditForm id={id} websiteId={websiteId} onClose={close} />
</Dialog>
);
}}
</ReportEditButton>
</Column>
{!isSharePage && (
<Column>
<ReportEditButton id={id} name={name} type={type}>
{({ close }) => {
return (
<Dialog
title={formatMessage(labels.funnel)}
style={{ minHeight: 300, minWidth: 400 }}
>
<FunnelEditForm id={id} websiteId={websiteId} onClose={close} />
</Dialog>
);
}}
</ReportEditButton>
</Column>
)}
</Grid>
{data?.map(
(

View file

@ -4,7 +4,7 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
import { SectionHeader } from '@/components/common/SectionHeader';
import { useDateRange, useReportsQuery } from '@/components/hooks';
import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks';
import { Funnel } from './Funnel';
import { FunnelAddButton } from './FunnelAddButton';
@ -13,13 +13,17 @@ export function FunnelsPage({ websiteId }: { websiteId: string }) {
const {
dateRange: { startDate, endDate },
} = useDateRange();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<SectionHeader>
<FunnelAddButton websiteId={websiteId} />
</SectionHeader>
{!isSharePage && (
<SectionHeader>
<FunnelAddButton websiteId={websiteId} />
</SectionHeader>
)}
<LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && (
<Grid gap>

View file

@ -1,6 +1,6 @@
import { Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useMessages, useResultQuery } from '@/components/hooks';
import { useMessages, useNavigation, useResultQuery } from '@/components/hooks';
import { File, User } from '@/components/icons';
import { ReportEditButton } from '@/components/input/ReportEditButton';
import { Lightning } from '@/components/svg';
@ -25,6 +25,8 @@ export type GoalData = { num: number; total: number };
export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) {
const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, {
websiteId,
startDate,
@ -45,21 +47,23 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
</Text>
</Row>
</Column>
<Column>
<ReportEditButton id={id} name={name} type={type}>
{({ close }) => {
return (
<Dialog
title={formatMessage(labels.goal)}
variant="modal"
style={{ minHeight: 300, minWidth: 400 }}
>
<GoalEditForm id={id} websiteId={websiteId} onClose={close} />
</Dialog>
);
}}
</ReportEditButton>
</Column>
{!isSharePage && (
<Column>
<ReportEditButton id={id} name={name} type={type}>
{({ close }) => {
return (
<Dialog
title={formatMessage(labels.goal)}
variant="modal"
style={{ minHeight: 300, minWidth: 400 }}
>
<GoalEditForm id={id} websiteId={websiteId} onClose={close} />
</Dialog>
);
}}
</ReportEditButton>
</Column>
)}
</Grid>
<Row alignItems="center" justifyContent="space-between" gap>
<Text color="muted">

View file

@ -4,7 +4,7 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
import { SectionHeader } from '@/components/common/SectionHeader';
import { useDateRange, useReportsQuery } from '@/components/hooks';
import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks';
import { Goal } from './Goal';
import { GoalAddButton } from './GoalAddButton';
@ -13,13 +13,17 @@ export function GoalsPage({ websiteId }: { websiteId: string }) {
const {
dateRange: { startDate, endDate },
} = useDateRange();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<SectionHeader>
<GoalAddButton websiteId={websiteId} />
</SectionHeader>
{!isSharePage && (
<SectionHeader>
<GoalAddButton websiteId={websiteId} />
</SectionHeader>
)}
<LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && (
<Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>

View file

@ -6,7 +6,13 @@ import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
import { Edit } from '@/components/icons';
import { ActiveUsers } from '@/components/metrics/ActiveUsers';
export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
export function WebsiteHeader({
showActions,
allowLink = true,
}: {
showActions?: boolean;
allowLink?: boolean;
}) {
const website = useWebsite();
const { renderUrl, pathname } = useNavigation();
const isSettings = pathname.endsWith('/settings');
@ -21,7 +27,7 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
<PageHeader
title={website.name}
icon={<Favicon domain={website.domain} />}
titleHref={renderUrl(`/websites/${website.id}`, false)}
titleHref={allowLink ? renderUrl(`/websites/${website.id}`, false) : undefined}
>
<Row alignItems="center" gap="6" wrap="wrap">
<ActiveUsers websiteId={website.id} />

View file

@ -1,81 +0,0 @@
import {
Button,
Checkbox,
Column,
Form,
FormField,
FormSubmitButton,
Row,
Text,
} from '@umami/react-zen';
import { useState } from 'react';
import { useApi, useMessages, useModified } from '@/components/hooks';
import { SHARE_NAV_ITEMS } from './constants';
export interface ShareCreateFormProps {
websiteId: string;
onSave?: () => void;
onClose?: () => void;
}
export function ShareCreateForm({ websiteId, onSave, onClose }: ShareCreateFormProps) {
const { formatMessage, labels } = useMessages();
const { post } = useApi();
const { touch } = useModified();
const [isPending, setIsPending] = useState(false);
// Build default values - only overview and events enabled by default
const defaultValues: Record<string, boolean> = {};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
defaultValues[item.id] = item.id === 'overview' || item.id === 'events';
});
});
const handleSubmit = async (data: any) => {
setIsPending(true);
try {
const parameters: Record<string, boolean> = {};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
parameters[item.id] = data[item.id] ?? false;
});
});
await post(`/websites/${websiteId}/shares`, { parameters });
touch('shares');
onSave?.();
onClose?.();
} finally {
setIsPending(false);
}
};
return (
<Form onSubmit={handleSubmit} defaultValues={defaultValues}>
<Column gap="3">
{SHARE_NAV_ITEMS.map(section => (
<Column key={section.section} gap="1">
<Text size="2" weight="bold">
{formatMessage((labels as any)[section.section])}
</Text>
<Column gap="1">
{section.items.map(item => (
<FormField key={item.id} name={item.id}>
<Checkbox>{formatMessage((labels as any)[item.label])}</Checkbox>
</FormField>
))}
</Column>
</Column>
))}
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton isDisabled={isPending}>{formatMessage(labels.save)}</FormSubmitButton>
</Row>
</Column>
</Form>
);
}

View file

@ -5,6 +5,7 @@ import {
Form,
FormField,
FormSubmitButton,
Grid,
Label,
Loading,
Row,
@ -13,25 +14,30 @@ import {
} from '@umami/react-zen';
import { useEffect, useState } from 'react';
import { useApi, useConfig, useMessages, useModified } from '@/components/hooks';
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
import { SHARE_NAV_ITEMS } from './constants';
export function ShareEditForm({
shareId,
websiteId,
onSave,
onClose,
}: {
shareId: string;
shareId?: string;
websiteId?: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, messages, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(`/share/id/${shareId}`);
const { formatMessage, labels, getErrorMessage } = useMessages();
const { cloudMode } = useConfig();
const { get } = useApi();
const { get, post } = useApi();
const { touch } = useModified();
const { modified } = useModified('shares');
const [share, setShare] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(!!shareId);
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<any>(null);
const isEditing = !!shareId;
const getUrl = (slug: string) => {
if (cloudMode) {
@ -41,6 +47,8 @@ export function ShareEditForm({
};
useEffect(() => {
if (!shareId) return;
const loadShare = async () => {
setIsLoading(true);
try {
@ -61,27 +69,35 @@ export function ShareEditForm({
});
});
await mutateAsync(
{ slug: share.slug, parameters },
{
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch('shares');
onSave?.();
onClose?.();
},
},
);
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 <Loading placement="absolute" />;
}
const url = getUrl(share?.slug || '');
const url = isEditing ? getUrl(share?.slug || '') : null;
// Build default values from share parameters
const defaultValues: Record<string, boolean> = {};
const defaultValues: Record<string, any> = {
name: share?.name || '',
};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
const defaultSelected = item.id === 'overview' || item.id === 'events';
@ -89,34 +105,60 @@ export function ShareEditForm({
});
});
// Get all item ids for validation
const allItemIds = SHARE_NAV_ITEMS.flatMap(section => section.items.map(item => item.id));
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={defaultValues}>
<Column gap="3">
<Column>
<Label>{formatMessage(labels.shareUrl)}</Label>
<TextField value={url} isReadOnly allowCopy />
</Column>
{SHARE_NAV_ITEMS.map(section => (
<Column key={section.section} gap="1">
<Text weight="bold">{formatMessage((labels as any)[section.section])}</Text>
<Column gap="1">
{section.items.map(item => (
<FormField key={item.id} name={item.id}>
<Checkbox>{formatMessage((labels as any)[item.label])}</Checkbox>
</FormField>
{({ watch }) => {
const values = watch();
const hasSelection = allItemIds.some(id => values[id]);
return (
<Column gap="6">
{url && (
<Column>
<Label>{formatMessage(labels.shareUrl)}</Label>
<TextField value={url} isReadOnly allowCopy />
</Column>
)}
<FormField
label={formatMessage(labels.name)}
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="off" autoFocus={!isEditing} />
</FormField>
<Grid columns="repeat(auto-fit, minmax(150px, 1fr))" gap="3">
{SHARE_NAV_ITEMS.map(section => (
<Column key={section.section} gap="3">
<Text weight="bold">{formatMessage((labels as any)[section.section])}</Text>
<Column gap="1">
{section.items.map(item => (
<FormField key={item.id} name={item.id}>
<Checkbox>{formatMessage((labels as any)[item.label])}</Checkbox>
</FormField>
))}
</Column>
</Column>
))}
</Column>
</Grid>
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton
variant="primary"
isDisabled={isPending || !hasSelection || !values.name}
>
{formatMessage(labels.save)}
</FormSubmitButton>
</Row>
</Column>
))}
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton variant="primary">{formatMessage(labels.save)}</FormSubmitButton>
</Row>
</Column>
);
}}
</Form>
);
}

View file

@ -10,14 +10,14 @@ export function SharesTable(props: DataTableProps) {
const { cloudMode } = useConfig();
const getUrl = (slug: string) => {
if (cloudMode) {
return `${process.env.cloudUrl}/share/${slug}`;
}
return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
return `${cloudMode ? process.env.cloudUrl : window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
};
return (
<DataTable {...props}>
<DataColumn id="name" label={formatMessage(labels.name)}>
{({ name }: any) => name}
</DataColumn>
<DataColumn id="slug" label={formatMessage(labels.shareUrl)}>
{({ slug }: any) => {
const url = getUrl(slug);

View file

@ -2,7 +2,7 @@ import { Column, Heading, Row, Text } from '@umami/react-zen';
import { Plus } from 'lucide-react';
import { useMessages, useWebsiteSharesQuery } from '@/components/hooks';
import { DialogButton } from '@/components/input/DialogButton';
import { ShareCreateForm } from './ShareCreateForm';
import { ShareEditForm } from './ShareEditForm';
import { SharesTable } from './SharesTable';
export interface WebsiteShareFormProps {
@ -25,9 +25,9 @@ export function WebsiteShareForm({ websiteId }: WebsiteShareFormProps) {
label={formatMessage(labels.add)}
title={formatMessage(labels.share)}
variant="primary"
width="400px"
width="600px"
>
{({ close }) => <ShareCreateForm websiteId={websiteId} onClose={close} />}
{({ close }) => <ShareEditForm websiteId={websiteId} onClose={close} />}
</DialogButton>
</Row>
{hasShares ? (

View file

@ -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<string | null> {
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<WhiteLabel | null> {
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,12 +52,25 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu
return notFound();
}
const data = {
const website = await getWebsite(share.entityId);
const data: Record<string, any> = {
shareId: share.id,
websiteId: share.entityId,
parameters: share.parameters,
};
const token = createToken(data, secret());
return json({ ...data, token });
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);
}

View file

@ -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);

View file

@ -44,6 +44,7 @@ export async function POST(
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
name: z.string().max(200),
parameters: anyObjectParam.optional(),
});
@ -54,7 +55,8 @@ export async function POST(
}
const { websiteId } = await params;
const { parameters = {} } = body;
const { name, parameters } = body;
const shareParameters = parameters ?? {};
if (!(await canUpdateWebsite(auth, websiteId))) {
return unauthorized();
@ -66,8 +68,9 @@ export async function POST(
id: uuid(),
entityId: websiteId,
shareType: ENTITY_TYPE.website,
name,
slug,
parameters,
parameters: shareParameters,
});
return json(share);

View file

@ -1,12 +0,0 @@
import { Row, Text } from '@umami/react-zen';
import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants';
export function Footer() {
return (
<Row as="footer" paddingY="6" justifyContent="flex-end">
<a href={HOMEPAGE_URL} target="_blank">
<Text weight="bold">umami</Text> {`v${CURRENT_VERSION}`}
</a>
</Row>
);
}

View file

@ -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 (
<Row as="header" justifyContent="space-between" alignItems="center" paddingY="3">
<a href="https://umami.is" target="_blank" rel="noopener">
<Row alignItems="center" gap>
<Icon>
<Logo />
</Icon>
<Text weight="bold">umami</Text>
</Row>
</a>
<Row alignItems="center" gap>
<ThemeButton />
<LanguageButton />
<PreferencesButton />
</Row>
</Row>
);
}

View file

@ -0,0 +1,23 @@
import { Row, Text } from '@umami/react-zen';
import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants';
import type { WhiteLabel } from '@/lib/types';
export function ShareFooter({ whiteLabel }: { whiteLabel?: WhiteLabel }) {
if (whiteLabel) {
return (
<Row as="footer" paddingY="6" justifyContent="flex-end">
<a href={whiteLabel.url} target="_blank">
<Text weight="bold">{whiteLabel.name}</Text>
</a>
</Row>
);
}
return (
<Row as="footer" paddingY="6" justifyContent="flex-end">
<a href={HOMEPAGE_URL} target="_blank">
<Text weight="bold">umami</Text> {`v${CURRENT_VERSION}`}
</a>
</Row>
);
}

View file

@ -0,0 +1,33 @@
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';
import type { WhiteLabel } from '@/lib/types';
export function ShareHeader({ whiteLabel }: { whiteLabel?: WhiteLabel }) {
const logoUrl = whiteLabel?.url || 'https://umami.is';
const logoName = whiteLabel?.name || 'umami';
const logoImage = whiteLabel?.image;
return (
<Row as="header" justifyContent="space-between" alignItems="center" paddingY="3">
<a href={logoUrl} target="_blank" rel="noopener">
<Row alignItems="center" gap>
{logoImage ? (
<img src={logoImage} alt={logoName} style={{ height: 24 }} />
) : (
<Icon>
<Logo />
</Icon>
)}
<Text weight="bold">{logoName}</Text>
</Row>
</a>
<Row alignItems="center" gap>
<ThemeButton />
<LanguageButton />
<PreferencesButton />
</Row>
</Row>
);
}

View file

@ -1,6 +1,7 @@
'use client';
import { Column, Grid, useTheme } from '@umami/react-zen';
import { useEffect } from 'react';
import { Column, Grid, Row, useTheme } from '@umami/react-zen';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo } from 'react';
import { AttributionPage } from '@/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage';
import { BreakdownPage } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage';
import { FunnelsPage } from '@/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage';
@ -18,8 +19,9 @@ 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';
import { MobileMenuButton } from '@/components/input/MobileMenuButton';
import { ShareFooter } from './ShareFooter';
import { ShareHeader } from './ShareHeader';
import { ShareNav } from './ShareNav';
const PAGE_COMPONENTS: Record<string, React.ComponentType<{ websiteId: string }>> = {
@ -39,9 +41,34 @@ const PAGE_COMPONENTS: Record<string, React.ComponentType<{ websiteId: string }>
attribution: AttributionPage,
};
// All section IDs that can be enabled/disabled via parameters
const ALL_SECTION_IDS = [
'overview',
'events',
'sessions',
'realtime',
'compare',
'breakdown',
'goals',
'funnels',
'journeys',
'retention',
'utm',
'revenue',
'attribution',
];
export function SharePage({ shareId, path = '' }: { shareId: string; path?: string }) {
const { shareToken, isLoading } = useShareTokenQuery(shareId);
const { setTheme } = useTheme();
const router = useRouter();
// Calculate allowed sections
const allowedSections = useMemo(() => {
if (!shareToken?.parameters) return [];
const params = shareToken.parameters;
return ALL_SECTION_IDS.filter(id => params[id] !== false);
}, [shareToken?.parameters]);
useEffect(() => {
const url = new URL(window?.location?.href);
@ -52,11 +79,31 @@ export function SharePage({ shareId, path = '' }: { shareId: string; path?: stri
}
}, []);
// Redirect to the only allowed section if there's just one and we're on the base path
useEffect(() => {
if (
allowedSections.length === 1 &&
allowedSections[0] !== 'overview' &&
(path === '' || path === 'overview')
) {
router.replace(`/share/${shareId}/${allowedSections[0]}`);
}
}, [allowedSections, shareId, path, router]);
if (isLoading || !shareToken) {
return null;
}
const { websiteId, parameters = {} } = shareToken;
const { websiteId, parameters = {}, whiteLabel } = shareToken;
// Redirect to only allowed section - return null while redirecting
if (
allowedSections.length === 1 &&
allowedSections[0] !== 'overview' &&
(path === '' || path === 'overview')
) {
return null;
}
// Check if the requested path is allowed
const pageKey = path || '';
@ -70,8 +117,16 @@ export function SharePage({ shareId, path = '' }: { shareId: string; path?: stri
return (
<Column backgroundColor="2">
<Header />
<Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
<Row display={{ xs: 'flex', lg: 'none' }} alignItems="center" gap padding="3">
<Grid columns="auto 1fr" flexGrow={1} backgroundColor="3" borderRadius>
<MobileMenuButton>
{({ close }) => {
return <ShareNav shareId={shareId} parameters={parameters} onItemClick={close} />;
}}
</MobileMenuButton>
</Grid>
</Row>
<Column
display={{ xs: 'none', lg: 'flex' }}
width="240px"
@ -80,18 +135,21 @@ export function SharePage({ shareId, path = '' }: { shareId: string; path?: stri
backgroundColor
marginRight="2"
>
<ShareNav shareId={shareId} parameters={parameters} />
<Column display={{ xs: 'none', lg: 'flex' }}>
<ShareNav shareId={shareId} parameters={parameters} />
</Column>
</Column>
<PageBody gap>
<WebsiteProvider websiteId={websiteId}>
<WebsiteHeader showActions={false} />
<ShareHeader whiteLabel={whiteLabel} />
<Column>
<WebsiteHeader showActions={false} />
<PageComponent websiteId={websiteId} />
</Column>
<ShareFooter whiteLabel={whiteLabel} />
</WebsiteProvider>
</PageBody>
</Grid>
<Footer />
</Column>
);
}

View file

@ -31,6 +31,7 @@ export function PageBody({
<Column
{...props}
width="100%"
minHeight="100vh"
paddingBottom="6"
maxWidth={maxWidth}
paddingX={{ xs: '3', md: '6' }}

View file

@ -26,7 +26,7 @@ export function WebsiteSelect({
const { user } = useLoginQuery();
const { data, isLoading } = useUserWebsitesQuery(
{ userId: user?.id, teamId },
{ search, pageSize: 10, includeTeams },
{ search, pageSize: 20, includeTeams },
);
const listItems: { id: string; name: string }[] = data?.data || [];

View file

@ -11,7 +11,6 @@ export function renderDateLabels(unit: string, locale: string) {
switch (unit) {
case 'minute':
return formatDate(d, 'h:mm', locale);
case 'hour':
return formatDate(d, 'p', locale);
case 'day':

View file

@ -141,3 +141,9 @@ export interface ApiError extends Error {
code?: string;
message: string;
}
export interface WhiteLabel {
name: string;
url: string;
image: string;
}

View file

@ -5,7 +5,11 @@ import type { Auth } from '@/lib/types';
import { getTeamUser } from '@/queries/prisma';
export async function canViewEntity({ user }: Auth, entityId: string) {
if (user?.isAdmin) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -25,6 +29,10 @@ export async function canViewEntity({ user }: Auth, entityId: string) {
}
export async function canUpdateEntity({ user }: Auth, entityId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -45,6 +53,10 @@ export async function canUpdateEntity({ user }: Auth, entityId: string) {
}
export async function canDeleteEntity({ user }: Auth, entityId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}

View file

@ -4,7 +4,11 @@ import type { Auth } from '@/lib/types';
import { getLink, getTeamUser } from '@/queries/prisma';
export async function canViewLink({ user }: Auth, linkId: string) {
if (user?.isAdmin) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -24,6 +28,10 @@ export async function canViewLink({ user }: Auth, linkId: string) {
}
export async function canUpdateLink({ user }: Auth, linkId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -44,6 +52,10 @@ export async function canUpdateLink({ user }: Auth, linkId: string) {
}
export async function canDeleteLink({ user }: Auth, linkId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}

View file

@ -4,7 +4,11 @@ import type { Auth } from '@/lib/types';
import { getPixel, getTeamUser } from '@/queries/prisma';
export async function canViewPixel({ user }: Auth, pixelId: string) {
if (user?.isAdmin) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -24,6 +28,10 @@ export async function canViewPixel({ user }: Auth, pixelId: string) {
}
export async function canUpdatePixel({ user }: Auth, pixelId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -44,6 +52,10 @@ export async function canUpdatePixel({ user }: Auth, pixelId: string) {
}
export async function canDeletePixel({ user }: Auth, pixelId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}

View file

@ -3,11 +3,11 @@ import type { Auth } from '@/lib/types';
import { canViewWebsite } from './website';
export async function canViewReport(auth: Auth, report: Report) {
if (auth.user.isAdmin) {
if (auth.user?.isAdmin) {
return true;
}
if (auth.user.id === report.userId) {
if (auth.user?.id === report.userId) {
return true;
}
@ -15,6 +15,10 @@ export async function canViewReport(auth: Auth, report: Report) {
}
export async function canUpdateReport({ user }: Auth, report: Report) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}

View file

@ -4,6 +4,10 @@ import type { Auth } from '@/lib/types';
import { getTeamUser } from '@/queries/prisma';
export async function canViewTeam({ user }: Auth, teamId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -12,6 +16,10 @@ export async function canViewTeam({ user }: Auth, teamId: string) {
}
export async function canCreateTeam({ user }: Auth) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -20,6 +28,10 @@ export async function canCreateTeam({ user }: Auth) {
}
export async function canUpdateTeam({ user }: Auth, teamId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -30,6 +42,10 @@ export async function canUpdateTeam({ user }: Auth, teamId: string) {
}
export async function canDeleteTeam({ user }: Auth, teamId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -40,6 +56,10 @@ export async function canDeleteTeam({ user }: Auth, teamId: string) {
}
export async function canDeleteTeamUser({ user }: Auth, teamId: string, removeUserId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -54,6 +74,10 @@ export async function canDeleteTeamUser({ user }: Auth, teamId: string, removeUs
}
export async function canCreateTeamWebsite({ user }: Auth, teamId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -64,5 +88,5 @@ export async function canCreateTeamWebsite({ user }: Auth, teamId: string) {
}
export async function canViewAllTeams({ user }: Auth) {
return user.isAdmin;
return user?.isAdmin ?? false;
}

View file

@ -1,10 +1,14 @@
import type { Auth } from '@/lib/types';
export async function canCreateUser({ user }: Auth) {
return user.isAdmin;
return user?.isAdmin ?? false;
}
export async function canViewUser({ user }: Auth, viewedUserId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -13,10 +17,14 @@ export async function canViewUser({ user }: Auth, viewedUserId: string) {
}
export async function canViewUsers({ user }: Auth) {
return user.isAdmin;
return user?.isAdmin ?? false;
}
export async function canUpdateUser({ user }: Auth, viewedUserId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -25,5 +33,5 @@ export async function canUpdateUser({ user }: Auth, viewedUserId: string) {
}
export async function canDeleteUser({ user }: Auth) {
return user.isAdmin;
return user?.isAdmin ?? false;
}

View file

@ -15,7 +15,7 @@ export async function canViewWebsite({ user, shareToken }: Auth, websiteId: stri
const entity = await getEntity(websiteId);
if (!entity) {
if (!entity || !user) {
return false;
}
@ -33,10 +33,14 @@ export async function canViewWebsite({ user, shareToken }: Auth, websiteId: stri
}
export async function canViewAllWebsites({ user }: Auth) {
return user.isAdmin;
return user?.isAdmin ?? false;
}
export async function canCreateWebsite({ user }: Auth) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -45,6 +49,10 @@ export async function canCreateWebsite({ user }: Auth) {
}
export async function canUpdateWebsite({ user }: Auth, websiteId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -69,6 +77,10 @@ export async function canUpdateWebsite({ user }: Auth, websiteId: string) {
}
export async function canDeleteWebsite({ user }: Auth, websiteId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
@ -93,6 +105,10 @@ export async function canDeleteWebsite({ user }: Auth, websiteId: string) {
}
export async function canTransferWebsiteToUser({ user }: Auth, websiteId: string, userId: string) {
if (!user) {
return false;
}
const website = await getWebsite(websiteId);
if (!website) {
@ -109,6 +125,10 @@ export async function canTransferWebsiteToUser({ user }: Auth, websiteId: string
}
export async function canTransferWebsiteToTeam({ user }: Auth, websiteId: string, teamId: string) {
if (!user) {
return false;
}
const website = await getWebsite(websiteId);
if (!website) {