diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx
index ecd1b52e7..f0a607c8b 100644
--- a/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx
+++ b/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx
@@ -1,12 +1,13 @@
'use client';
-import { Button, Column, Icon, 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 { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
import { Panel } from '@/components/common/Panel';
-import { useMessages, useSubscription } from '@/components/hooks';
+import { useMessages, useSubscription, useWebsite } from '@/components/hooks';
import { Video } from '@/components/icons';
import { getItem, setItem } from '@/lib/storage';
+import { Button, Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
+import { type Key, useState } from 'react';
import { ReplayModal } from './ReplayModal';
import { ReplaysDataTable } from './ReplaysDataTable';
import { SavedReplaysDataTable } from './SavedReplaysDataTable';
@@ -15,8 +16,9 @@ const KEY_NAME = 'umami.replays.tab';
export function ReplaysPage({ websiteId }: { websiteId: string }) {
const [tab, setTab] = useState(getItem(KEY_NAME) || 'replays');
+ const website = useWebsite();
const { t, labels, messages } = useMessages();
- const { hasFeature, cloudMode } = useSubscription();
+ const { hasFeature, cloudMode } = useSubscription(website?.teamId);
const handleSelect = (value: Key) => {
setItem(KEY_NAME, value);
@@ -27,18 +29,18 @@ export function ReplaysPage({ websiteId }: { websiteId: string }) {
return (
-
-
-
-
- {t(messages.upgradeRequired, { plan: 'Business' })}
+ }
+ title={t(messages.upgradeRequired, { plan: 'Business' })}
+ description="Watch real user sessions to see exactly how visitors interact with your site."
+ >
-
+
);
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx
index b84fb8cd7..3c9d37564 100644
--- a/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx
@@ -7,11 +7,12 @@ import {
Select,
Slider,
Switch,
- Text,
TextField,
} from '@umami/react-zen';
import { useState } from 'react';
+import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
import { useMessages, useSubscription, useUpdateQuery, useWebsite } from '@/components/hooks';
+import { Video } from '@/components/icons';
interface ReplayConfig {
sampleRate?: number;
@@ -23,7 +24,7 @@ interface ReplayConfig {
export function WebsiteReplaySettings({ websiteId }: { websiteId: string }) {
const website = useWebsite();
const { t, labels, messages } = useMessages();
- const { hasFeature, cloudMode } = useSubscription();
+ const { hasFeature, cloudMode } = useSubscription(website?.teamId);
const { mutateAsync, touch, toast, isPending } = useUpdateQuery(`/websites/${websiteId}`);
const [enabled, setEnabled] = useState(website?.replayEnabled ?? false);
@@ -81,12 +82,18 @@ export function WebsiteReplaySettings({ websiteId }: { websiteId: string }) {
return (
-
- {t(messages.upgradeRequired, { plan: 'Business' })}
-
+
);
}
diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts
index 1c611e6f3..526e8c9f8 100644
--- a/src/app/api/auth/verify/route.ts
+++ b/src/app/api/auth/verify/route.ts
@@ -1,4 +1,4 @@
-import { fetchAccount } from '@/lib/load';
+import { fetchAccount, fetchTeam } from '@/lib/load';
import { parseRequest } from '@/lib/request';
import { json } from '@/lib/response';
import { getAllUserTeams } from '@/queries/prisma';
@@ -20,9 +20,29 @@ export async function POST(request: Request) {
user.subscription = {
isPro: account.isPro || false,
isBusiness: account.isBusiness || false,
+ isNoBilling: account.isNoBilling || false,
hasSubscription: account.hasSubscription || false,
};
}
+
+ const teamsWithSubscription = await Promise.all(
+ teams.map(async (team: any) => {
+ const teamAccount = await fetchTeam(team.id);
+ return {
+ ...team,
+ subscription: teamAccount
+ ? {
+ isPro: teamAccount.isPro || false,
+ isBusiness: teamAccount.isBusiness || false,
+ isNoBilling: teamAccount.isNoBilling || false,
+ hasSubscription: teamAccount.hasSubscription || false,
+ }
+ : null,
+ };
+ }),
+ );
+
+ return json({ ...user, teams: teamsWithSubscription });
}
return json({ ...user, teams });
diff --git a/src/app/api/record/route.ts b/src/app/api/record/route.ts
index ed70db078..28ce0f40c 100644
--- a/src/app/api/record/route.ts
+++ b/src/app/api/record/route.ts
@@ -4,7 +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 { fetchAccount, fetchTeam } from '@/lib/load';
import { parseRequest } from '@/lib/request';
import { badRequest, forbidden, json, serverError } from '@/lib/response';
import { getWebsite } from '@/queries/prisma';
@@ -62,9 +62,13 @@ export async function POST(request: Request) {
}
if (process.env.CLOUD_MODE) {
- const account = await fetchAccount(website.userId);
+ const account = website.teamId
+ ? await fetchTeam(website.teamId)
+ : website.userId
+ ? await fetchAccount(website.userId)
+ : null;
- if (!account?.isBusiness) {
+ if (!account?.isBusiness && !account?.isNoBilling) {
return forbidden({ message: 'Business subscription required.' });
}
}
diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts
index c571f4055..2e2424a2f 100644
--- a/src/app/api/teams/route.ts
+++ b/src/app/api/teams/route.ts
@@ -1,6 +1,8 @@
import { z } from 'zod';
import { uuid } from '@/lib/crypto';
import { getRandomChars } from '@/lib/generate';
+import { fetchAccount } from '@/lib/load';
+import redis from '@/lib/redis';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams } from '@/lib/schema';
@@ -43,14 +45,35 @@ export async function POST(request: Request) {
const { name, ownerId } = body;
+ const teamId = uuid();
+ const teamOwnerId = ownerId ?? auth.user.id;
+
const team = await createTeam(
{
- id: uuid(),
+ id: teamId,
name,
accessCode: `team_${getRandomChars(16)}`,
},
- ownerId ?? auth.user.id,
+ teamOwnerId,
);
+ if (process.env.CLOUD_MODE && redis.enabled) {
+ const account = await fetchAccount(teamOwnerId);
+
+ if (account) {
+ await redis.client.set(
+ `team:${teamId}`,
+ {
+ teamOwnerId,
+ isPro: account.isPro || false,
+ isBusiness: account.isBusiness || false,
+ isNoBilling: account.isNoBilling || false,
+ hasSubscription: account.hasSubscription || false,
+ },
+ 60 * 60 * 24 * 90,
+ );
+ }
+ }
+
return json(team);
}
diff --git a/src/components/hooks/useSubscription.ts b/src/components/hooks/useSubscription.ts
index a57edb4f8..e596b35c5 100644
--- a/src/components/hooks/useSubscription.ts
+++ b/src/components/hooks/useSubscription.ts
@@ -4,6 +4,7 @@ import { useConfig } from './useConfig';
export interface Subscription {
isPro: boolean;
isBusiness: boolean;
+ isNoBilling: boolean;
hasSubscription: boolean;
}
@@ -16,18 +17,24 @@ export type FeatureName = keyof typeof FEATURES;
const defaultSubscription: Subscription = {
isPro: false,
isBusiness: false,
+ isNoBilling: false,
hasSubscription: false,
};
-export function useSubscription() {
+export function useSubscription(teamId?: string | null) {
const { user } = useApp();
const config = useConfig();
- const subscription: Subscription = user?.subscription || defaultSubscription;
+ const ownSubscription: Subscription = user?.subscription || defaultSubscription;
+ const teamSubscription: Subscription | null = teamId
+ ? user?.teams?.find((t: any) => t.id === teamId)?.subscription ?? null
+ : null;
+
+ const subscription: Subscription = teamSubscription || ownSubscription;
const cloudMode = config?.cloudMode || false;
function hasFeature(feature: FeatureName): boolean {
- if (!cloudMode) {
+ if (!cloudMode || subscription.isNoBilling) {
return true;
}
diff --git a/src/lib/load.ts b/src/lib/load.ts
index bf527975f..f2c9c7981 100644
--- a/src/lib/load.ts
+++ b/src/lib/load.ts
@@ -44,3 +44,9 @@ export async function fetchAccount(userId: string) {
return account;
}
+
+export async function fetchTeam(teamId: string) {
+ const team = await redis.client.get(`team:${teamId}`);
+
+ return team;
+}
diff --git a/src/queries/prisma/team.ts b/src/queries/prisma/team.ts
index 5987c1d7b..de938df35 100644
--- a/src/queries/prisma/team.ts
+++ b/src/queries/prisma/team.ts
@@ -100,6 +100,13 @@ export async function getAllUserTeams(userId: string) {
});
}
+export async function getTeamOwner(teamId: string) {
+ return prisma.client.teamUser.findFirst({
+ where: { teamId, role: ROLES.teamOwner },
+ select: { userId: true },
+ });
+}
+
export async function createTeam(data: Prisma.TeamCreateInput, userId: string): Promise {
const { id } = data;
const { client, transaction } = prisma;