From 6e1a05f686f350f4b0ab97475e3d27681ca2b79a Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Sun, 15 Mar 2026 18:03:36 -0700 Subject: [PATCH] Gate replays. --- public/intl/messages/en-US.json | 2 + .../[websiteId]/replays/ReplaysPage.tsx | 22 ++++++++-- .../settings/WebsiteReplaySettings.tsx | 18 +++++++- src/app/api/auth/verify/route.ts | 18 +++++++- src/app/api/record/route.ts | 9 ++++ src/components/hooks/index.ts | 1 + src/components/hooks/useSubscription.ts | 43 +++++++++++++++++++ src/components/messages.ts | 2 + 8 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 src/components/hooks/useSubscription.ts diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index 1d8f3be30..fc29387ec 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -348,6 +348,7 @@ "saved": "Saved", "unknown": "Unknown", "untitled": "Untitled", + "upgrade": "Upgrade", "update": "Update", "url": "URL", "user": "User", @@ -426,6 +427,7 @@ "unauthorized": "Unauthorized", "user-deleted": "User deleted.", "viewed-page": "Viewed page", + "upgrade-required": "This feature requires a {plan} plan subscription.", "visitor-log": "Visitor from {country} using {browser} on {os} {device}" } } diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx index 6c473b592..adfde5bac 100644 --- a/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx @@ -1,10 +1,10 @@ 'use client'; -import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; +import { Button, Column, Tab, TabList, TabPanel, Tabs, Text } from '@umami/react-zen'; import { type Key, useState } from 'react'; import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { Panel } from '@/components/common/Panel'; -import { useMessages } from '@/components/hooks'; +import { useMessages, useSubscription } from '@/components/hooks'; import { getItem, setItem } from '@/lib/storage'; import { ReplayModal } from './ReplayModal'; import { ReplaysDataTable } from './ReplaysDataTable'; @@ -14,13 +14,29 @@ const KEY_NAME = 'umami.replays.tab'; export function ReplaysPage({ websiteId }: { websiteId: string }) { const [tab, setTab] = useState(getItem(KEY_NAME) || 'replays'); - const { t, labels } = useMessages(); + const { t, labels, messages } = useMessages(); + const { hasFeature, cloudMode } = useSubscription(); const handleSelect = (value: Key) => { setItem(KEY_NAME, value); setTab(value); }; + if (cloudMode && !hasFeature('replays')) { + return ( + + + + {t(messages.upgradeRequired, { plan: 'Business' })} + + + + + ); + } + return ( diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx index d88923d78..b84fb8cd7 100644 --- a/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx @@ -7,10 +7,11 @@ import { Select, Slider, Switch, + Text, TextField, } from '@umami/react-zen'; import { useState } from 'react'; -import { useMessages, useUpdateQuery, useWebsite } from '@/components/hooks'; +import { useMessages, useSubscription, useUpdateQuery, useWebsite } from '@/components/hooks'; interface ReplayConfig { sampleRate?: number; @@ -22,6 +23,7 @@ interface ReplayConfig { export function WebsiteReplaySettings({ websiteId }: { websiteId: string }) { const website = useWebsite(); const { t, labels, messages } = useMessages(); + const { hasFeature, cloudMode } = useSubscription(); const { mutateAsync, touch, toast, isPending } = useUpdateQuery(`/websites/${websiteId}`); const [enabled, setEnabled] = useState(website?.replayEnabled ?? false); @@ -75,6 +77,20 @@ export function WebsiteReplaySettings({ websiteId }: { websiteId: string }) { ); }; + if (cloudMode && !hasFeature('replays')) { + return ( + + + + {t(messages.upgradeRequired, { plan: 'Business' })} + + + + ); + } + return ( diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts index b308b7b69..1c611e6f3 100644 --- a/src/app/api/auth/verify/route.ts +++ b/src/app/api/auth/verify/route.ts @@ -1,3 +1,4 @@ +import { fetchAccount } from '@/lib/load'; import { parseRequest } from '@/lib/request'; import { json } from '@/lib/response'; import { getAllUserTeams } from '@/queries/prisma'; @@ -9,7 +10,20 @@ export async function POST(request: Request) { return error(); } - const teams = await getAllUserTeams(auth.user.id); + const user = { ...auth.user }; + const teams = await getAllUserTeams(user.id); - return json({ ...auth.user, teams }); + if (process.env.CLOUD_MODE) { + const account = await fetchAccount(user.id); + + if (account) { + user.subscription = { + isPro: account.isPro || false, + isBusiness: account.isBusiness || false, + hasSubscription: account.hasSubscription || false, + }; + } + } + + return json({ ...user, teams }); } diff --git a/src/app/api/record/route.ts b/src/app/api/record/route.ts index 390b25b49..ed70db078 100644 --- a/src/app/api/record/route.ts +++ b/src/app/api/record/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { secret } from '@/lib/crypto'; import { getClientInfo, hasBlockedIp } from '@/lib/detect'; import { parseToken } from '@/lib/jwt'; +import { fetchAccount } from '@/lib/load'; import { parseRequest } from '@/lib/request'; import { badRequest, forbidden, json, serverError } from '@/lib/response'; import { getWebsite } from '@/queries/prisma'; @@ -60,6 +61,14 @@ export async function POST(request: Request) { return json({ ok: false, reason: 'replay_disabled' }); } + if (process.env.CLOUD_MODE) { + const account = await fetchAccount(website.userId); + + if (!account?.isBusiness) { + return forbidden({ message: 'Business subscription required.' }); + } + } + // Client info for bot/IP checks const { ip, userAgent } = await getClientInfo(request, {}); diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index d683ce403..604a87a5f 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -96,5 +96,6 @@ export * from './usePageParameters'; export * from './useRegionNames'; export * from './useSlug'; export * from './useSticky'; +export * from './useSubscription'; export * from './useTimezone'; export * from './useWebsiteNavItems'; diff --git a/src/components/hooks/useSubscription.ts b/src/components/hooks/useSubscription.ts new file mode 100644 index 000000000..a57edb4f8 --- /dev/null +++ b/src/components/hooks/useSubscription.ts @@ -0,0 +1,43 @@ +import { useApp } from '@/store/app'; +import { useConfig } from './useConfig'; + +export interface Subscription { + isPro: boolean; + isBusiness: boolean; + hasSubscription: boolean; +} + +const FEATURES = { + replays: 'isBusiness', +} as const; + +export type FeatureName = keyof typeof FEATURES; + +const defaultSubscription: Subscription = { + isPro: false, + isBusiness: false, + hasSubscription: false, +}; + +export function useSubscription() { + const { user } = useApp(); + const config = useConfig(); + + const subscription: Subscription = user?.subscription || defaultSubscription; + const cloudMode = config?.cloudMode || false; + + function hasFeature(feature: FeatureName): boolean { + if (!cloudMode) { + return true; + } + + const requiredFlag = FEATURES[feature]; + return subscription[requiredFlag] || false; + } + + return { + ...subscription, + cloudMode, + hasFeature, + }; +} diff --git a/src/components/messages.ts b/src/components/messages.ts index f5a3d6bca..e91390bf8 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -378,6 +378,7 @@ export const labels: Record = { retentionDays: 'label.retention-days', duration: 'label.duration', recorded: 'label.recorded', + upgrade: 'label.upgrade', }; export const messages: Record = { @@ -427,4 +428,5 @@ export const messages: Record = { forbidden: 'message.forbidden', notFound: 'message.not-found', serverError: 'message.sever-error', + upgradeRequired: 'message.upgrade-required', };