use team redis keys to resolve team gated features

This commit is contained in:
Francis Cao
2026-03-21 11:21:07 -07:00
parent 0a4dcebed7
commit cd2b303e89
7 changed files with 58 additions and 13 deletions
@@ -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 (
<Column gap="3">
<Panel>
@@ -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);
+21 -1
View File
@@ -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 });
+7 -3
View File
@@ -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.' });
}
}
+10 -3
View File
@@ -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;
}
+6
View File
@@ -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;
}
+7
View File
@@ -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<any> {
const { id } = data;
const { client, transaction } = prisma;