From cd2b303e89ca5a2bf5ddfaf42a92f594fe61d107 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Sat, 21 Mar 2026 11:21:07 -0700 Subject: [PATCH] use team redis keys to resolve team gated features --- .../[websiteId]/replays/ReplaysPage.tsx | 11 +++++----- .../settings/WebsiteReplaySettings.tsx | 2 +- src/app/api/auth/verify/route.ts | 22 ++++++++++++++++++- src/app/api/record/route.ts | 10 ++++++--- src/components/hooks/useSubscription.ts | 13 ++++++++--- src/lib/load.ts | 6 +++++ src/queries/prisma/team.ts | 7 ++++++ 7 files changed, 58 insertions(+), 13 deletions(-) diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx index 217908a4a..9dee34567 100644 --- a/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx @@ -1,12 +1,12 @@ '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 { 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, Icon, Tab, TabList, TabPanel, Tabs, Text } from '@umami/react-zen'; +import { type Key, useState } from 'react'; import { ReplayModal } from './ReplayModal'; import { ReplaysDataTable } from './ReplaysDataTable'; import { SavedReplaysDataTable } from './SavedReplaysDataTable'; @@ -15,15 +15,16 @@ 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, hasSubscription, cloudMode } = useSubscription(); + const { hasFeature, cloudMode } = useSubscription(website?.teamId); const handleSelect = (value: Key) => { setItem(KEY_NAME, value); setTab(value); }; - if (cloudMode && hasSubscription && !hasFeature('replays')) { + if (cloudMode && !hasFeature('replays')) { return ( diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx index b84fb8cd7..3a65d1e85 100644 --- a/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx @@ -23,7 +23,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); 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/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;