feat: add feature opt-in banner system (#26625)
* docs: add feature opt-in banner system plan Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * docs: iterate on feature opt-in banner plan based on feedback - Use config.ts for feature metadata instead of TRPC response - Consolidate localStorage dismissal into single key to avoid bloat - Replace separate success dialog with in-dialog success state for smoother UX Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * feat: implement feature opt-in banner system - Add checkFeatureOptInEligibility TRPC procedure to check if user can opt-in - Create useFeatureOptInBanner hook with localStorage dismissal state - Create FeatureOptInBanner floating component for bottom-right display - Create FeatureOptInConfirmDialog with role-based options and success state - Integrate banner into bookings-view.tsx as example usage Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * feat: add i18n translation keys for feature opt-in banner * refactor: create FeatureOptInBannerWrapper for simpler consumer integration Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: use @calcom/lib/webstorage and add Zod schema for localStorage Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: address PR review comments - use PBAC, move logic to service, improve typing Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> # Conflicts: # packages/features/feature-opt-in/services/FeatureOptInService.ts * test: add comprehensive tests for checkFeatureOptInEligibility method Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: correct import and Checkbox type errors Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * update message * invalidate after dialog dismiss * feat: simulate user opt-in to determine banner eligibility When a feature has a strict policy and org/team hasn't explicitly enabled it, user opt-in alone won't enable the feature. This change simulates what would happen if the user opts in and only shows the banner if opting in would actually enable the feature. Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: add early return for missing featureConfig and clarify simulation comment Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix repository usage * refactor: simplify FeatureOptInConfirmDialog UI and remove unused translation Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: replace radio buttons with multi-select dropdown in FeatureOptInConfirmDialog Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * resize banner * refactor: use AnimatedPopover pattern for team selection and rename titleI18nKey to nameI18nKey Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: change 'Just for me' to 'For me', fix width clipping, remove divider Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * update style * feat: make 'Just for me' mutually exclusive with teams/org and store opt-ins in localStorage Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: remove barrel imports and update to direct imports Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: add modal prop to AnimatedPopover to fix scroll lock conflict in dialogs Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: wrap Popover.Content with Portal to prevent layout interference in dialogs Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: use flexbox gap instead of space-y to prevent popover wrapper from affecting layout Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * Revert "fix: use flexbox gap instead of space-y to prevent popover wrapper from affecting layout" This reverts commit 8385adafa890c2f2674aa3ae3b10ca6e0c55ba5c. * Revert "fix: wrap Popover.Content with Portal to prevent layout interference in dialogs" This reverts commit e84301a35a99b3559d47c2d37316af290834ca2d. * Revert "fix: add modal prop to AnimatedPopover to fix scroll lock conflict in dialogs" This reverts commit b015af2f1cf31ef7087c02cb8535d96cc4a34125. * feat: replace Radix Dialog and AnimatedPopover with coss-ui components This migration uses @coss/ui Dialog and Popover components which are built on Base UI instead of Radix. Base UI handles scroll locking and positioning differently, which should fix the gap issue when opening the popover inside the dialog. Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: replace remaining @calcom/ui components with coss-ui - Replace Divider with Separator from @coss/ui - Replace Label with Label from @coss/ui - Replace CheckboxField with Checkbox + Label from @coss/ui - Replace Icon with direct lucide-react icons (UserIcon, BuildingIcon, UsersIcon, CheckIcon) - Replace showToast with toastManager.add() from @coss/ui Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: replace Popover with Menu component from coss-ui - Replace Popover + FilterCheckboxField with Menu + MenuCheckboxItem - MenuCheckboxItem provides built-in checkbox indicator and styling - MenuSeparator provides proper menu separators - Cleaner API with onCheckedChange callback - Better keyboard navigation support Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: align icon and text on same line in MenuCheckboxItem, widen dialog - Wrap icon and text in flex container with items-center gap-2 - Change dialog width from sm:max-w-md to sm:max-w-lg to prevent text wrapping Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * use dimmed bg for coss dialog title * refactor: change nameI18nKey/descriptionI18nKey to nested i18n object - Change OptInFeatureConfig interface to use i18n: { name, description } - Update all component usages to use featureConfig.i18n.name and featureConfig.i18n.description - Update test mocks to use new structure Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * feat: add i18n.title to OptInFeatureConfig type definition Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * update texts * use title instead of name * update banner style * refactor: split FeatureOptInConfirmDialog into separate success and confirmation dialogs Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: don't record dismissed info in local storage after successful opt-in Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: add checkFeatureOptInEligibility to IFeatureOptInService interface Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * update common.json * refactor: move tRPC usage from features package to apps/web/modules Addresses Cubic AI review feedback (confidence 9/10): - Remove @calcom/trpc import from FeatureOptInConfirmDialog.tsx - Add FeatureOptInMutations type for passing mutation functions via props - Move tRPC hooks to useFeatureOptInBanner hook in apps/web/modules - Pass mutations through FeatureOptInBannerWrapper to dialog component This maintains the architectural constraint that packages/features should not import from @calcom/trpc to prevent circular dependencies. Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
28 files changed
+1591
-104
No files matched your search
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnFilterType, DataTableProvider, type SystemFilterSegment } from "@calcom/features/data-table";
|
||||
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
||||
import FeatureOptInBannerWrapper from "@calcom/features/feature-opt-in/components/FeatureOptInBannerWrapper";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import dynamic from "next/dynamic";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { DataTableProvider, type SystemFilterSegment, ColumnFilterType } from "@calcom/features/data-table";
|
||||
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
|
||||
import { useFeatureOptInBanner } from "../../feature-opt-in/hooks/useFeatureOptInBanner";
|
||||
import { BookingListContainer } from "../components/BookingListContainer";
|
||||
import { useBookingsShellHeadingVisibility } from "../hooks/useBookingsShellHeadingVisibility";
|
||||
import { useBookingsView } from "../hooks/useBookingsView";
|
||||
@@ -71,6 +72,7 @@ export default function Bookings(props: BookingsProps) {
|
||||
|
||||
function BookingsContent({ status, permissions, bookingsV3Enabled, bookingAuditEnabled }: BookingsProps) {
|
||||
const [view] = useBookingsView({ bookingsV3Enabled });
|
||||
const optInBanner = useFeatureOptInBanner("bookings-v3");
|
||||
|
||||
useBookingsShellHeadingVisibility({ visible: view === "list" });
|
||||
|
||||
@@ -91,6 +93,7 @@ function BookingsContent({ status, permissions, bookingsV3Enabled, bookingAuditE
|
||||
bookingsV3Enabled={bookingsV3Enabled}
|
||||
/>
|
||||
)}
|
||||
<FeatureOptInBannerWrapper state={optInBanner} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { useOrganizationFeatureOptIn } from "~/feature-opt-in/hooks";
|
||||
import { useOrganizationFeatureOptIn } from "~/feature-opt-in/hooks/useOrganizationFeatureOptIn";
|
||||
|
||||
interface OrganizationFeaturesViewProps {
|
||||
canEdit: boolean;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export { useUserFeatureOptIn } from "./useUserFeatureOptIn";
|
||||
export { useTeamFeatureOptIn } from "./useTeamFeatureOptIn";
|
||||
export { useOrganizationFeatureOptIn } from "./useOrganizationFeatureOptIn";
|
||||
export type { UseFeatureOptInResult, NormalizedFeature, ToggleLabels } from "@calcom/features/feature-opt-in/types";
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config";
|
||||
import { getOptInFeatureConfig } from "@calcom/features/feature-opt-in/config";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
const DISMISSED_STORAGE_KEY = "feature-opt-in-dismissed";
|
||||
const OPTED_IN_STORAGE_KEY = "feature-opt-in-enabled";
|
||||
|
||||
const featuresMapSchema = z.record(z.string(), z.boolean());
|
||||
|
||||
type FeaturesMap = z.infer<typeof featuresMapSchema>;
|
||||
|
||||
type UserRoleContext = {
|
||||
isOrgAdmin: boolean;
|
||||
orgId: number | null;
|
||||
adminTeamIds: number[];
|
||||
adminTeamNames: { id: number; name: string }[];
|
||||
};
|
||||
|
||||
export type FeatureOptInMutations = {
|
||||
setUserState: (params: { slug: string; state: "enabled" }) => Promise<unknown>;
|
||||
setTeamState: (params: { teamId: number; slug: string; state: "enabled" }) => Promise<unknown>;
|
||||
setOrganizationState: (params: { slug: string; state: "enabled" }) => Promise<unknown>;
|
||||
setUserAutoOptIn: (params: { autoOptIn: boolean }) => Promise<unknown>;
|
||||
setTeamAutoOptIn: (params: { teamId: number; autoOptIn: boolean }) => Promise<unknown>;
|
||||
setOrganizationAutoOptIn: (params: { autoOptIn: boolean }) => Promise<unknown>;
|
||||
invalidateQueries: () => void;
|
||||
};
|
||||
|
||||
export type UseFeatureOptInBannerResult = {
|
||||
shouldShow: boolean;
|
||||
isLoading: boolean;
|
||||
featureConfig: OptInFeatureConfig | null;
|
||||
canOptIn: boolean;
|
||||
blockingReason: string | null;
|
||||
userRoleContext: UserRoleContext | null;
|
||||
isDialogOpen: boolean;
|
||||
openDialog: () => void;
|
||||
closeDialog: () => void;
|
||||
dismiss: () => void;
|
||||
markOptedIn: () => void;
|
||||
mutations: FeatureOptInMutations;
|
||||
};
|
||||
|
||||
function getFeaturesMap(storageKey: string): FeaturesMap {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (!stored) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
const result = featuresMapSchema.safeParse(parsed);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function setFeatureInMap(storageKey: string, featureId: string): void {
|
||||
const current = getFeaturesMap(storageKey);
|
||||
current[featureId] = true;
|
||||
localStorage.setItem(storageKey, JSON.stringify(current));
|
||||
}
|
||||
|
||||
function isFeatureInMap(storageKey: string, featureId: string): boolean {
|
||||
const features = getFeaturesMap(storageKey);
|
||||
return features[featureId] === true;
|
||||
}
|
||||
|
||||
export function useFeatureOptInBanner(featureId: string): UseFeatureOptInBannerResult {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isDismissed, setIsDismissed] = useState(() => isFeatureInMap(DISMISSED_STORAGE_KEY, featureId));
|
||||
const [isOptedIn, setIsOptedIn] = useState(() => isFeatureInMap(OPTED_IN_STORAGE_KEY, featureId));
|
||||
|
||||
const featureConfig = useMemo(() => getOptInFeatureConfig(featureId) ?? null, [featureId]);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const eligibilityQuery = trpc.viewer.featureOptIn.checkFeatureOptInEligibility.useQuery(
|
||||
{ featureId },
|
||||
{
|
||||
enabled: !isDismissed && !isOptedIn && featureConfig !== null,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
}
|
||||
);
|
||||
|
||||
const setUserStateMutation = trpc.viewer.featureOptIn.setUserState.useMutation();
|
||||
const setTeamStateMutation = trpc.viewer.featureOptIn.setTeamState.useMutation();
|
||||
const setOrganizationStateMutation = trpc.viewer.featureOptIn.setOrganizationState.useMutation();
|
||||
const setUserAutoOptInMutation = trpc.viewer.featureOptIn.setUserAutoOptIn.useMutation();
|
||||
const setTeamAutoOptInMutation = trpc.viewer.featureOptIn.setTeamAutoOptIn.useMutation();
|
||||
const setOrganizationAutoOptInMutation = trpc.viewer.featureOptIn.setOrganizationAutoOptIn.useMutation();
|
||||
|
||||
const mutations: FeatureOptInMutations = useMemo(
|
||||
() => ({
|
||||
setUserState: (params) => setUserStateMutation.mutateAsync(params),
|
||||
setTeamState: (params) => setTeamStateMutation.mutateAsync(params),
|
||||
setOrganizationState: (params) => setOrganizationStateMutation.mutateAsync(params),
|
||||
setUserAutoOptIn: (params) => setUserAutoOptInMutation.mutateAsync(params),
|
||||
setTeamAutoOptIn: (params) => setTeamAutoOptInMutation.mutateAsync(params),
|
||||
setOrganizationAutoOptIn: (params) => setOrganizationAutoOptInMutation.mutateAsync(params),
|
||||
invalidateQueries: () => {
|
||||
utils.viewer.featureOptIn.checkFeatureOptInEligibility.invalidate();
|
||||
utils.viewer.featureOptIn.listForUser.invalidate();
|
||||
},
|
||||
}),
|
||||
[
|
||||
setUserStateMutation,
|
||||
setTeamStateMutation,
|
||||
setOrganizationStateMutation,
|
||||
setUserAutoOptInMutation,
|
||||
setTeamAutoOptInMutation,
|
||||
setOrganizationAutoOptInMutation,
|
||||
utils,
|
||||
]
|
||||
);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setFeatureInMap(DISMISSED_STORAGE_KEY, featureId);
|
||||
setIsDismissed(true);
|
||||
}, [featureId]);
|
||||
|
||||
const markOptedIn = useCallback(() => {
|
||||
setFeatureInMap(OPTED_IN_STORAGE_KEY, featureId);
|
||||
setIsOptedIn(true);
|
||||
}, [featureId]);
|
||||
|
||||
const openDialog = useCallback(() => {
|
||||
setIsDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setIsDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const shouldShow = useMemo(() => {
|
||||
if (isDismissed) return false;
|
||||
if (isOptedIn) return false;
|
||||
if (!featureConfig) return false;
|
||||
if (eligibilityQuery.isLoading) return false;
|
||||
if (!eligibilityQuery.data) return false;
|
||||
return eligibilityQuery.data.status === "can_opt_in";
|
||||
}, [isDismissed, isOptedIn, featureConfig, eligibilityQuery.isLoading, eligibilityQuery.data]);
|
||||
|
||||
return {
|
||||
shouldShow,
|
||||
isLoading: eligibilityQuery.isLoading,
|
||||
featureConfig,
|
||||
canOptIn: eligibilityQuery.data?.canOptIn ?? false,
|
||||
blockingReason: eligibilityQuery.data?.blockingReason ?? null,
|
||||
userRoleContext: eligibilityQuery.data?.userRoleContext ?? null,
|
||||
isDialogOpen,
|
||||
openDialog,
|
||||
closeDialog,
|
||||
dismiss,
|
||||
markOptedIn,
|
||||
mutations,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types";
|
||||
import type { FeatureState } from "@calcom/features/flags/config";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
function useMutationCallbacks(onSuccessCallback: () => void): { onSuccess: () => void; onError: () => void } {
|
||||
const { t } = useLocale();
|
||||
@@ -56,7 +55,10 @@ export function useOrganizationFeatureOptIn(): UseFeatureOptInResult {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const invalidateFeatures = useCallback(() => utils.viewer.featureOptIn.listForOrganization.invalidate(), [utils]);
|
||||
const invalidateFeatures = useCallback(
|
||||
() => utils.viewer.featureOptIn.listForOrganization.invalidate(),
|
||||
[utils]
|
||||
);
|
||||
const invalidateFeaturesAndAutoOptIn = useCallback(() => {
|
||||
utils.viewer.featureOptIn.getOrganizationAutoOptIn.invalidate();
|
||||
utils.viewer.featureOptIn.listForOrganization.invalidate();
|
||||
@@ -65,13 +67,15 @@ export function useOrganizationFeatureOptIn(): UseFeatureOptInResult {
|
||||
const setStateMutationCallbacks = useMutationCallbacks(invalidateFeatures);
|
||||
const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn);
|
||||
|
||||
const setStateMutation = trpc.viewer.featureOptIn.setOrganizationState.useMutation(setStateMutationCallbacks);
|
||||
const setStateMutation =
|
||||
trpc.viewer.featureOptIn.setOrganizationState.useMutation(setStateMutationCallbacks);
|
||||
const setAutoOptInMutation = trpc.viewer.featureOptIn.setOrganizationAutoOptIn.useMutation(
|
||||
setAutoOptInMutationCallbacks
|
||||
);
|
||||
|
||||
const features = normalizeFeatures(featuresQuery.data);
|
||||
const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ slug, state });
|
||||
const setFeatureState = (slug: string, state: FeatureState): void =>
|
||||
setStateMutation.mutate({ slug, state });
|
||||
const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ autoOptIn: checked });
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import type { TFunction } from "i18next";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types";
|
||||
import type { FeatureState } from "@calcom/features/flags/config";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
type FeatureBlockingState = { orgState?: FeatureState };
|
||||
type TeamFeatureData = { featureId: string; globalEnabled: boolean; teamState: FeatureState; orgState: FeatureState };
|
||||
type TeamFeatureData = {
|
||||
featureId: string;
|
||||
globalEnabled: boolean;
|
||||
teamState: FeatureState;
|
||||
orgState: FeatureState;
|
||||
};
|
||||
|
||||
function useMutationCallbacks(onSuccessCallback: () => void): { onSuccess: () => void; onError: () => void } {
|
||||
const { t } = useLocale();
|
||||
@@ -73,8 +77,14 @@ export function useTeamFeatureOptIn(teamId: number): UseFeatureOptInResult {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const featuresQuery = trpc.viewer.featureOptIn.listForTeam.useQuery({ teamId }, { refetchOnWindowFocus: false });
|
||||
const autoOptInQuery = trpc.viewer.featureOptIn.getTeamAutoOptIn.useQuery({ teamId }, { refetchOnWindowFocus: false });
|
||||
const featuresQuery = trpc.viewer.featureOptIn.listForTeam.useQuery(
|
||||
{ teamId },
|
||||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
const autoOptInQuery = trpc.viewer.featureOptIn.getTeamAutoOptIn.useQuery(
|
||||
{ teamId },
|
||||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
const invalidateFeatures = useCallback(
|
||||
() => utils.viewer.featureOptIn.listForTeam.invalidate({ teamId }),
|
||||
@@ -89,12 +99,16 @@ export function useTeamFeatureOptIn(teamId: number): UseFeatureOptInResult {
|
||||
const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn);
|
||||
|
||||
const setStateMutation = trpc.viewer.featureOptIn.setTeamState.useMutation(setStateMutationCallbacks);
|
||||
const setAutoOptInMutation = trpc.viewer.featureOptIn.setTeamAutoOptIn.useMutation(setAutoOptInMutationCallbacks);
|
||||
const setAutoOptInMutation = trpc.viewer.featureOptIn.setTeamAutoOptIn.useMutation(
|
||||
setAutoOptInMutationCallbacks
|
||||
);
|
||||
|
||||
const featureBlockingState = useMemo(() => buildBlockingStateMap(featuresQuery.data), [featuresQuery.data]);
|
||||
const features = normalizeTeamFeatures(featuresQuery.data);
|
||||
const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ teamId, slug, state });
|
||||
const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ teamId, autoOptIn: checked });
|
||||
const setFeatureState = (slug: string, state: FeatureState): void =>
|
||||
setStateMutation.mutate({ teamId, slug, state });
|
||||
const setAutoOptIn = (checked: boolean): void =>
|
||||
setAutoOptInMutation.mutate({ teamId, autoOptIn: checked });
|
||||
const getBlockedWarning = createTeamBlockedWarningFn(featureBlockingState, t);
|
||||
const isBlockedByHigherLevel = createTeamIsBlockedByHigherLevelFn(featureBlockingState);
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import type { EffectiveStateReason } from "@calcom/features/feature-opt-in/lib/computeEffectiveState";
|
||||
import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types";
|
||||
import type { FeatureState } from "@calcom/features/flags/config";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
type UserFeatureData = {
|
||||
featureId: string;
|
||||
@@ -53,8 +51,9 @@ function createUserBlockedWarningFn(t: TFunction): (feature: NormalizedFeature)
|
||||
case "feature_any_team_disabled":
|
||||
return t("feature_blocked_by_team_warning");
|
||||
case "feature_no_explicit_enablement":
|
||||
case "feature_user_only_not_allowed":
|
||||
return t("feature_no_explicit_enablement_warning");
|
||||
case "feature_user_only_not_allowed":
|
||||
return t("feature_user_only_not_allowed_warning");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -76,8 +75,12 @@ export function useUserFeatureOptIn(): UseFeatureOptInResult {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const featuresQuery = trpc.viewer.featureOptIn.listForUser.useQuery(undefined, { refetchOnWindowFocus: false });
|
||||
const autoOptInQuery = trpc.viewer.featureOptIn.getUserAutoOptIn.useQuery(undefined, { refetchOnWindowFocus: false });
|
||||
const featuresQuery = trpc.viewer.featureOptIn.listForUser.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const autoOptInQuery = trpc.viewer.featureOptIn.getUserAutoOptIn.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const invalidateFeatures = useCallback(() => utils.viewer.featureOptIn.listForUser.invalidate(), [utils]);
|
||||
const invalidateFeaturesAndAutoOptIn = useCallback(() => {
|
||||
@@ -89,10 +92,13 @@ export function useUserFeatureOptIn(): UseFeatureOptInResult {
|
||||
const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn);
|
||||
|
||||
const setStateMutation = trpc.viewer.featureOptIn.setUserState.useMutation(setStateMutationCallbacks);
|
||||
const setAutoOptInMutation = trpc.viewer.featureOptIn.setUserAutoOptIn.useMutation(setAutoOptInMutationCallbacks);
|
||||
const setAutoOptInMutation = trpc.viewer.featureOptIn.setUserAutoOptIn.useMutation(
|
||||
setAutoOptInMutationCallbacks
|
||||
);
|
||||
|
||||
const features = useMemo(() => normalizeUserFeatures(featuresQuery.data), [featuresQuery.data]);
|
||||
const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ slug, state });
|
||||
const setFeatureState = (slug: string, state: FeatureState): void =>
|
||||
setStateMutation.mutate({ slug, state });
|
||||
const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ autoOptIn: checked });
|
||||
const getBlockedWarning = createUserBlockedWarningFn(t);
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { useUserFeatureOptIn } from "~/feature-opt-in/hooks";
|
||||
import { useUserFeatureOptIn } from "~/feature-opt-in/hooks/useUserFeatureOptIn";
|
||||
|
||||
const FeaturesView = (): ReactElement => {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { useTeamFeatureOptIn } from "~/feature-opt-in/hooks";
|
||||
import { useTeamFeatureOptIn } from "~/feature-opt-in/hooks/useTeamFeatureOptIn";
|
||||
|
||||
interface TeamFeaturesViewProps {
|
||||
teamId: number;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -130,8 +130,9 @@
|
||||
"please_try_again_later_or_book_another_slot": "You've just tried connecting now. Please try again later in {{remaining}} minutes or book another slot from the booking page.",
|
||||
"unavailable_timeslot_title": "Unavailable timeslot",
|
||||
"booking_time_out_of_bounds_error": "The event type cannot be booked at this time. Could you try another time slot?",
|
||||
"bookings_v3_title": "Enhanced bookings",
|
||||
"bookings_v3_description": "Switch between list and calendar views with details in a slide-over.",
|
||||
"bookings_v3_title": "Try the new bookings page",
|
||||
"bookings_v3_name": "Enhanced bookings",
|
||||
"bookings_v3_description": "A redesigned booking page including a calendar view.",
|
||||
"request_body_end_time_internal_error": "Internal Error. Request body does not contain end time",
|
||||
"create_calendar_event_error": "Unable to create calendar event in organizer's calendar",
|
||||
"update_calendar_event_error": "Unable to update calendar event.",
|
||||
@@ -3865,11 +3866,11 @@
|
||||
"license_key": "License key",
|
||||
"license_selection_title": "Choose your license",
|
||||
"license_selection_description": "Select how you want to license your Cal.com deployment",
|
||||
"agplv3_license_description": "Use Cal.com under the AGPLv3 open source license",
|
||||
"agplv3_license_description": "Use Cal.com under the AGPLv3 open source license",
|
||||
"enter_license_key": "Enter license key",
|
||||
"enter_your_license_key": "Access to all enterprise features",
|
||||
"enter_existing_license": "Starting at $15/user per month",
|
||||
"enter_license_key_description": "Access to all enterprise features",
|
||||
"enter_license_key_description": "Access to all enterprise features",
|
||||
"license_key_placeholder": "Enter your license key",
|
||||
"save_license_key": "Save license key",
|
||||
"signature_token_optional": "Signature token (optional)",
|
||||
@@ -4333,6 +4334,7 @@
|
||||
"feature_blocked_by_team_warning": "This feature is disabled by your team.",
|
||||
"feature_blocked_by_org_warning": "This feature is disabled by your organization.",
|
||||
"feature_no_explicit_enablement_warning": "Turn it on for yourself, or ask your team or organization admin to enable it for everyone.",
|
||||
"feature_user_only_not_allowed_warning": "This feature needs approval from your team or organization.",
|
||||
"no_opt_in_features_available": "No opt-in features are currently available",
|
||||
"enabled_via_auto_opt_in": "Enabled via auto opt-in",
|
||||
"account_already_exists_please_login": "An account with this email already exists. Please log in to accept the invitation.",
|
||||
@@ -4340,6 +4342,19 @@
|
||||
"pbac_resource_feature_opt_in": "Feature Opt-In",
|
||||
"pbac_desc_view_feature_opt_in": "View feature opt-in settings",
|
||||
"pbac_desc_update_feature_opt_in": "Manage feature opt-in settings",
|
||||
"try_it": "Try it",
|
||||
"enable_for": "Enable for",
|
||||
"entire_organization": "Entire organization",
|
||||
"my_teams_count": "My teams ({{count}})",
|
||||
"just_for_me": "Just for me",
|
||||
"for_me": "For me",
|
||||
"auto_opt_in_future_features_org": "Automatically opt into future features for my organization",
|
||||
"auto_opt_in_future_features_teams": "Automatically opt into future features for my teams",
|
||||
"auto_opt_in_future_features_user": "Automatically opt into future features",
|
||||
"feature_enabled_successfully": "Feature enabled successfully!",
|
||||
"feature_enabled_description": "You can manage this feature in your settings.",
|
||||
"view_settings": "View Settings",
|
||||
"error_enabling_feature": "Error enabling feature. Please try again.",
|
||||
"set_organizer_as_contact_owner": "Set booking organizer as contact owner",
|
||||
"overwrite_existing_contact_owner": "Overwrite existing contact owner",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS":"↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
|
||||
@@ -177,3 +177,7 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
}
|
||||
|
||||
.backdrop-blur-sm {
|
||||
@apply backdrop-blur-none bg-neutral-800/70;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
# Feature Opt-In Banner System Plan
|
||||
|
||||
This document outlines the high-level plan for implementing a feature opt-in banner/popup system that allows users to opt into experimental features directly from the pages where those features apply.
|
||||
|
||||
## Overview
|
||||
|
||||
When a new opt-in feature (like "bookings-v3") is available, users will see a floating banner on relevant pages. Clicking the banner opens a confirmation dialog where users can opt-in based on their role (org owner, team owner, or regular user).
|
||||
|
||||
---
|
||||
|
||||
## 1. Backend: New TRPC Procedure
|
||||
|
||||
**Location:** `packages/trpc/server/routers/viewer/featureOptIn/_router.ts`
|
||||
|
||||
**New Procedure:** `checkFeatureOptInEligibility`
|
||||
|
||||
This procedure will:
|
||||
- Accept a `featureId` parameter (e.g., "bookings-v3")
|
||||
- Use the existing `resolveFeatureStatesAcrossTeams` function from `FeatureOptInService`
|
||||
- Return one of three response types:
|
||||
1. **Already Enabled**: User effectively has the feature enabled - no banner needed
|
||||
2. **Can Opt-In**: User can opt-in - return user's role context (feature metadata like title/description comes from `getOptInFeatureConfig()` in `config.ts`)
|
||||
3. **Cannot Opt-In**: User is blocked by org/team (strict policy) - return blocking reason
|
||||
|
||||
The response should include:
|
||||
- User's role context for the confirmation dialog:
|
||||
- Is org owner/admin?
|
||||
- Which teams is user owner/admin of?
|
||||
- Organization ID (if applicable)
|
||||
- Team IDs where user has admin rights
|
||||
|
||||
---
|
||||
|
||||
## 2. Frontend: Custom Hook
|
||||
|
||||
**Location:** New file in `apps/web/modules/feature-opt-in/hooks/`
|
||||
|
||||
**Hook:** `useFeatureOptInBanner(featureId: string)`
|
||||
|
||||
This hook will:
|
||||
1. **Check localStorage first** for dismissal state in a single consolidated key `feature-opt-in-dismissed`
|
||||
- The key stores an object mapping feature IDs to dismissal status: `{ "bookings-v3": true, "insights-v2": true }`
|
||||
- If the feature is dismissed, skip the TRPC call entirely and return `{ shouldShow: false }`
|
||||
- Using a single key avoids localStorage bloat from multiple per-feature keys
|
||||
2. **Call the TRPC procedure** if not dismissed
|
||||
3. **Get feature metadata** from `getOptInFeatureConfig(featureId)` in `config.ts` for title/description i18n keys
|
||||
4. **Return state** for the banner:
|
||||
- `shouldShow: boolean`
|
||||
- `featureConfig: OptInFeatureConfig` (from config.ts, includes titleI18nKey, descriptionI18nKey)
|
||||
- `canOptIn: boolean`
|
||||
- `blockingReason: string | null`
|
||||
- `userRoleContext: { isOrgAdmin, adminTeamIds, orgId }`
|
||||
- `dismiss(): void` - updates the consolidated localStorage key
|
||||
- `openDialog(): void` - triggers confirmation dialog
|
||||
|
||||
---
|
||||
|
||||
## 3. UI Components
|
||||
|
||||
### 3a. Floating Banner Component
|
||||
|
||||
**Location:** `packages/features/feature-opt-in/components/FeatureOptInBanner.tsx`
|
||||
|
||||
A minimal floating banner positioned at bottom-right:
|
||||
- Small rectangle with feature title and brief description
|
||||
- Close button (X) - calls `dismiss()` to save to localStorage
|
||||
- Clickable area - opens confirmation dialog
|
||||
- Can be refined later for design polish
|
||||
|
||||
### 3b. Confirmation Dialog
|
||||
|
||||
**Location:** `packages/features/feature-opt-in/components/FeatureOptInConfirmDialog.tsx`
|
||||
|
||||
Complex dialog with role-based options:
|
||||
|
||||
**For Org Owners/Admins:**
|
||||
- Radio/checkbox to choose scope:
|
||||
- "Enable for entire organization"
|
||||
- "Enable just for me"
|
||||
- Auto opt-in checkbox: "Automatically opt into future features for my organization"
|
||||
|
||||
**For Team Owners/Admins (not org admin):**
|
||||
- Radio/checkbox to choose scope:
|
||||
- "Enable for my teams" (list team names if multiple)
|
||||
- "Enable just for me"
|
||||
- Auto opt-in checkbox: "Automatically opt into future features for my teams"
|
||||
|
||||
**For Regular Users:**
|
||||
- Simple confirmation message
|
||||
- Auto opt-in checkbox: "Automatically opt into future features"
|
||||
|
||||
**CTA Button:** "Enable Feature" - calls appropriate mutation(s) based on selection
|
||||
|
||||
### 3c. Success State (within Confirmation Dialog)
|
||||
|
||||
Instead of a separate success dialog (which would cause UI flashing), the confirmation dialog handles the success state internally:
|
||||
|
||||
**Flow:**
|
||||
1. User clicks "Enable Feature" button
|
||||
2. Button shows a loading spinner while the mutation runs
|
||||
3. On success, the dialog content transitions to show success state
|
||||
4. Success state displays:
|
||||
- "Successfully enabled!" message
|
||||
- Two buttons:
|
||||
1. "Dismiss" - closes dialog
|
||||
2. "View Settings" - redirects to appropriate settings page
|
||||
|
||||
**Redirect Logic:**
|
||||
- If user opted in for org: `/settings/organizations/features`
|
||||
- If user opted in for team(s): `/settings/teams/[first-team-id]/features`
|
||||
- If user opted in for self only: `/settings/my-account/features`
|
||||
|
||||
This approach provides a smoother UX by avoiding the jarring close-and-reopen of separate dialogs.
|
||||
|
||||
---
|
||||
|
||||
## 4. Integration Points
|
||||
|
||||
**Example:** `apps/web/modules/bookings/views/bookings-view.tsx`
|
||||
|
||||
At the view level:
|
||||
1. Import and call the hook: `const optInBanner = useFeatureOptInBanner("bookings-v3")`
|
||||
2. Conditionally render the banner: `{optInBanner.shouldShow && <FeatureOptInBanner {...optInBanner} />}`
|
||||
|
||||
This pattern can be replicated for any view where a feature opt-in banner is needed.
|
||||
|
||||
---
|
||||
|
||||
## 5. Policy Considerations
|
||||
|
||||
The existing `computeEffectiveStateAcrossTeams` function already handles the two policies:
|
||||
|
||||
**Permissive Policy:**
|
||||
- User can opt-in even if org/teams haven't explicitly enabled
|
||||
- Only blocked if ALL teams have explicitly disabled
|
||||
|
||||
**Strict Policy:**
|
||||
- User cannot opt-in alone; requires org/team to enable first
|
||||
- ANY explicit disable from org/team blocks the user
|
||||
|
||||
The TRPC procedure should use the feature's policy (from `OPT_IN_FEATURES` config) to determine if the user can opt-in.
|
||||
|
||||
---
|
||||
|
||||
## 6. Data Flow Summary
|
||||
|
||||
```
|
||||
View (bookings-view.tsx)
|
||||
|
|
||||
v
|
||||
useFeatureOptInBanner("bookings-v3")
|
||||
|
|
||||
v
|
||||
[Check localStorage for dismissal]
|
||||
| (if not dismissed)
|
||||
v
|
||||
TRPC: checkFeatureOptInEligibility
|
||||
|
|
||||
v
|
||||
FeatureOptInService.resolveFeatureStatesAcrossTeams
|
||||
|
|
||||
v
|
||||
[Return eligibility + user role context]
|
||||
|
|
||||
v
|
||||
Render FeatureOptInBanner (if eligible)
|
||||
| (on click)
|
||||
v
|
||||
FeatureOptInConfirmDialog
|
||||
| (on confirm)
|
||||
v
|
||||
Existing mutations: setUserState / setTeamState / setOrganizationState
|
||||
|
|
||||
v
|
||||
[Dialog transitions to success state]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Key Dependencies on PR #25892
|
||||
|
||||
This plan relies on these existing pieces from the underlying PR:
|
||||
- `FeatureOptInService` and `resolveFeatureStatesAcrossTeams`
|
||||
- `computeEffectiveStateAcrossTeams` with policy support
|
||||
- `OPT_IN_FEATURES` config with policy field
|
||||
- Existing mutations for setting user/team/org states
|
||||
- Settings pages at `/settings/*/features`
|
||||
|
||||
---
|
||||
|
||||
## 8. Files to Create/Modify
|
||||
|
||||
### New Files:
|
||||
- `packages/features/feature-opt-in/components/FeatureOptInBanner.tsx`
|
||||
- `packages/features/feature-opt-in/components/FeatureOptInConfirmDialog.tsx` (includes success state handling)
|
||||
- `apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.ts`
|
||||
|
||||
### Modified Files:
|
||||
- `packages/trpc/server/routers/viewer/featureOptIn/_router.ts` (add new procedure)
|
||||
- `apps/web/modules/bookings/views/bookings-view.tsx` (integrate banner)
|
||||
- Translation files for new i18n keys
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import Image from "next/image";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
interface FeatureOptInBannerProps {
|
||||
featureConfig: OptInFeatureConfig;
|
||||
onDismiss: () => void;
|
||||
onOpenDialog: () => void;
|
||||
}
|
||||
|
||||
export function FeatureOptInBanner({
|
||||
featureConfig,
|
||||
onDismiss,
|
||||
onOpenDialog,
|
||||
}: FeatureOptInBannerProps): ReactElement {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="bg-default border-subtle fixed bottom-5 right-5 z-50 max-w-xs rounded-lg border shadow-lg group">
|
||||
<div className="p-4">
|
||||
<h3 className="text-emphasis text-lg font-semibold">{t(featureConfig.i18n.title)}</h3>
|
||||
<p className="text-subtle mt-1 text-base">{t(featureConfig.i18n.description)}</p>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
variant="icon"
|
||||
StartIcon="x"
|
||||
color="minimal"
|
||||
className="absolute top-1.5 right-1.5 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto"
|
||||
aria-label={t("close")}></Button>
|
||||
|
||||
<Button className="mt-3" size="sm" color="secondary" onClick={onOpenDialog}>
|
||||
{t("try_it")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ aspectRatio: featureConfig.bannerImage.width / featureConfig.bannerImage.height }}>
|
||||
<Image
|
||||
src={featureConfig.bannerImage.src}
|
||||
alt={t(featureConfig.i18n.title)}
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FeatureOptInBanner;
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { FeatureOptInBanner } from "./FeatureOptInBanner";
|
||||
import type { FeatureOptInMutations } from "./FeatureOptInConfirmDialog";
|
||||
import { FeatureOptInConfirmDialog } from "./FeatureOptInConfirmDialog";
|
||||
|
||||
type UserRoleContext = {
|
||||
isOrgAdmin: boolean;
|
||||
orgId: number | null;
|
||||
adminTeamIds: number[];
|
||||
adminTeamNames: { id: number; name: string }[];
|
||||
};
|
||||
|
||||
type FeatureOptInBannerState = {
|
||||
shouldShow: boolean;
|
||||
isLoading: boolean;
|
||||
featureConfig: OptInFeatureConfig | null;
|
||||
canOptIn: boolean;
|
||||
blockingReason: string | null;
|
||||
userRoleContext: UserRoleContext | null;
|
||||
isDialogOpen: boolean;
|
||||
openDialog: () => void;
|
||||
closeDialog: () => void;
|
||||
dismiss: () => void;
|
||||
markOptedIn: () => void;
|
||||
mutations: FeatureOptInMutations;
|
||||
};
|
||||
|
||||
interface FeatureOptInBannerWrapperProps {
|
||||
state: FeatureOptInBannerState;
|
||||
}
|
||||
|
||||
function FeatureOptInBannerWrapper({ state }: FeatureOptInBannerWrapperProps): ReactElement | null {
|
||||
if (!state.featureConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{state.shouldShow && (
|
||||
<FeatureOptInBanner
|
||||
featureConfig={state.featureConfig}
|
||||
onDismiss={state.dismiss}
|
||||
onOpenDialog={state.openDialog}
|
||||
/>
|
||||
)}
|
||||
{state.userRoleContext && (
|
||||
<FeatureOptInConfirmDialog
|
||||
isOpen={state.isDialogOpen}
|
||||
onClose={state.closeDialog}
|
||||
onDismissBanner={state.dismiss}
|
||||
onOptInSuccess={state.markOptedIn}
|
||||
featureConfig={state.featureConfig}
|
||||
userRoleContext={state.userRoleContext}
|
||||
mutations={state.mutations}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FeatureOptInBannerWrapper;
|
||||
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@coss/ui/components/button";
|
||||
import { Checkbox } from "@coss/ui/components/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogPanel,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@coss/ui/components/dialog";
|
||||
import { Label } from "@coss/ui/components/label";
|
||||
import { Menu, MenuCheckboxItem, MenuPopup, MenuSeparator, MenuTrigger } from "@coss/ui/components/menu";
|
||||
import { toastManager } from "@coss/ui/components/toast";
|
||||
import { BuildingIcon, ChevronDownIcon, UserIcon, UsersIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ReactElement } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { FeatureOptInSuccessDialog } from "./FeatureOptInSuccessDialog";
|
||||
|
||||
type UserRoleContext = {
|
||||
isOrgAdmin: boolean;
|
||||
orgId: number | null;
|
||||
adminTeamIds: number[];
|
||||
adminTeamNames: { id: number; name: string }[];
|
||||
};
|
||||
|
||||
export type FeatureOptInMutations = {
|
||||
setUserState: (params: { slug: string; state: "enabled" }) => Promise<unknown>;
|
||||
setTeamState: (params: { teamId: number; slug: string; state: "enabled" }) => Promise<unknown>;
|
||||
setOrganizationState: (params: { slug: string; state: "enabled" }) => Promise<unknown>;
|
||||
setUserAutoOptIn: (params: { autoOptIn: boolean }) => Promise<unknown>;
|
||||
setTeamAutoOptIn: (params: { teamId: number; autoOptIn: boolean }) => Promise<unknown>;
|
||||
setOrganizationAutoOptIn: (params: { autoOptIn: boolean }) => Promise<unknown>;
|
||||
invalidateQueries: () => void;
|
||||
};
|
||||
|
||||
interface FeatureOptInConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDismissBanner: () => void;
|
||||
onOptInSuccess?: () => void;
|
||||
featureConfig: OptInFeatureConfig;
|
||||
userRoleContext: UserRoleContext;
|
||||
mutations: FeatureOptInMutations;
|
||||
}
|
||||
|
||||
export function FeatureOptInConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onDismissBanner,
|
||||
onOptInSuccess,
|
||||
featureConfig,
|
||||
userRoleContext,
|
||||
mutations,
|
||||
}: FeatureOptInConfirmDialogProps): ReactElement {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const { isOrgAdmin, orgId, adminTeamIds, adminTeamNames } = userRoleContext;
|
||||
const hasAdminTeams = adminTeamIds.length > 0;
|
||||
const canEnableForOrg = isOrgAdmin && orgId !== null;
|
||||
const canEnableForTeams = hasAdminTeams;
|
||||
const showSelector = canEnableForOrg || canEnableForTeams;
|
||||
|
||||
const [enableForUser, setEnableForUser] = useState(true);
|
||||
const [enableForOrg, setEnableForOrg] = useState(false);
|
||||
const [selectedTeamIds, setSelectedTeamIds] = useState<number[]>([]);
|
||||
const [autoOptIn, setAutoOptIn] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [shouldInvalidate, setShouldInvalidate] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const hasTeamsSelected = selectedTeamIds.length > 0;
|
||||
const hasAnySelection = enableForUser || enableForOrg || hasTeamsSelected;
|
||||
|
||||
const getSelectedText = (): string => {
|
||||
const parts: string[] = [];
|
||||
if (enableForUser) parts.push(t("just_for_me"));
|
||||
if (enableForOrg) parts.push(t("entire_organization"));
|
||||
if (hasTeamsSelected) {
|
||||
const teamNames = adminTeamNames
|
||||
.filter((team) => selectedTeamIds.includes(team.id))
|
||||
.map((team) => team.name);
|
||||
parts.push(...teamNames);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(", ") : t("select");
|
||||
};
|
||||
|
||||
const handleUserChange = (checked: boolean): void => {
|
||||
setEnableForUser(checked);
|
||||
if (checked) {
|
||||
setEnableForOrg(false);
|
||||
setSelectedTeamIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOrgChange = (checked: boolean): void => {
|
||||
setEnableForOrg(checked);
|
||||
if (checked) {
|
||||
setEnableForUser(false);
|
||||
setSelectedTeamIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTeamChange = (teamId: number, checked: boolean): void => {
|
||||
if (checked) {
|
||||
setSelectedTeamIds((prev) => [...prev, teamId]);
|
||||
setEnableForUser(false);
|
||||
setEnableForOrg(false);
|
||||
} else {
|
||||
setSelectedTeamIds((prev) => prev.filter((id) => id !== teamId));
|
||||
}
|
||||
};
|
||||
|
||||
const getAutoOptInText = (): string => {
|
||||
if (enableForOrg) {
|
||||
return t("auto_opt_in_future_features_org");
|
||||
}
|
||||
if (hasTeamsSelected) {
|
||||
return t("auto_opt_in_future_features_teams");
|
||||
}
|
||||
return t("auto_opt_in_future_features_user");
|
||||
};
|
||||
|
||||
const handleConfirm = async (): Promise<void> => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
if (enableForOrg && orgId) {
|
||||
promises.push(mutations.setOrganizationState({ slug: featureConfig.slug, state: "enabled" }));
|
||||
if (autoOptIn) {
|
||||
promises.push(mutations.setOrganizationAutoOptIn({ autoOptIn: true }));
|
||||
}
|
||||
}
|
||||
|
||||
if (hasTeamsSelected) {
|
||||
selectedTeamIds.forEach((teamId) => {
|
||||
promises.push(mutations.setTeamState({ teamId, slug: featureConfig.slug, state: "enabled" }));
|
||||
if (autoOptIn) {
|
||||
promises.push(mutations.setTeamAutoOptIn({ teamId, autoOptIn: true }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (enableForUser) {
|
||||
promises.push(mutations.setUserState({ slug: featureConfig.slug, state: "enabled" }));
|
||||
if (autoOptIn) {
|
||||
promises.push(mutations.setUserAutoOptIn({ autoOptIn: true }));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
setIsSuccess(true);
|
||||
setShouldInvalidate(true);
|
||||
onOptInSuccess?.();
|
||||
} catch (_error) {
|
||||
toastManager.add({ title: t("error_enabling_feature"), type: "error" });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSettingsRedirectPath = (): string => {
|
||||
if (enableForOrg) {
|
||||
return "/settings/organizations/features";
|
||||
}
|
||||
if (hasTeamsSelected && selectedTeamIds.length > 0) {
|
||||
return `/settings/teams/${selectedTeamIds[0]}/features`;
|
||||
}
|
||||
return "/settings/my-account/features";
|
||||
};
|
||||
|
||||
const handleViewSettings = (): void => {
|
||||
resetAndClose();
|
||||
router.push(getSettingsRedirectPath());
|
||||
};
|
||||
|
||||
const handleDismiss = (): void => {
|
||||
resetAndClose();
|
||||
};
|
||||
|
||||
const resetAndClose = (): void => {
|
||||
if (shouldInvalidate) {
|
||||
mutations.invalidateQueries();
|
||||
setShouldInvalidate(false);
|
||||
}
|
||||
setIsSuccess(false);
|
||||
setEnableForUser(true);
|
||||
setEnableForOrg(false);
|
||||
setSelectedTeamIds([]);
|
||||
setAutoOptIn(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<FeatureOptInSuccessDialog
|
||||
isOpen={isOpen}
|
||||
onClose={resetAndClose}
|
||||
onDismiss={handleDismiss}
|
||||
onViewSettings={handleViewSettings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={resetAndClose}>
|
||||
<DialogPopup className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(featureConfig.i18n.name)}</DialogTitle>
|
||||
<DialogDescription>{t(featureConfig.i18n.description)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogPanel>
|
||||
<div className="space-y-4">
|
||||
{showSelector && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("enable_for")}</Label>
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="border-default bg-default text-default hover:bg-muted flex w-full items-center justify-between rounded-md border px-3 py-2 text-sm"
|
||||
/>
|
||||
}>
|
||||
<span className="truncate leading-normal">{getSelectedText()}</span>
|
||||
<ChevronDownIcon aria-hidden="true" className="ml-2 h-4 w-4 shrink-0" />
|
||||
</MenuTrigger>
|
||||
<MenuPopup className="w-56" align="start">
|
||||
<MenuCheckboxItem
|
||||
checked={enableForUser}
|
||||
onCheckedChange={(checked) => handleUserChange(checked)}>
|
||||
<span className="flex items-center gap-2">
|
||||
<UserIcon className="h-4 w-4" />
|
||||
{t("just_for_me")}
|
||||
</span>
|
||||
</MenuCheckboxItem>
|
||||
|
||||
{canEnableForOrg && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuCheckboxItem
|
||||
checked={enableForOrg}
|
||||
onCheckedChange={(checked) => handleOrgChange(checked)}>
|
||||
<span className="flex items-center gap-2">
|
||||
<BuildingIcon className="h-4 w-4" />
|
||||
{t("entire_organization")}
|
||||
</span>
|
||||
</MenuCheckboxItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canEnableForTeams && adminTeamNames.length > 0 && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
{adminTeamNames.map((team) => (
|
||||
<MenuCheckboxItem
|
||||
key={team.id}
|
||||
checked={selectedTeamIds.includes(team.id)}
|
||||
onCheckedChange={(checked) => handleTeamChange(team.id, checked)}>
|
||||
<span className="flex items-center gap-2">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
{team.name}
|
||||
</span>
|
||||
</MenuCheckboxItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</MenuPopup>
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Label className="hover:bg-subtle flex cursor-pointer items-center gap-2 rounded-md p-1">
|
||||
<Checkbox checked={autoOptIn} onCheckedChange={(checked) => setAutoOptIn(checked === true)} />
|
||||
<span className="text-default text-sm">{getAutoOptInText()}</span>
|
||||
</Label>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />} onClick={resetAndClose} disabled={isSubmitting}>
|
||||
{t("cancel")}
|
||||
</DialogClose>
|
||||
<Button onClick={handleConfirm} disabled={isSubmitting || !hasAnySelection}>
|
||||
{t("enable")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default FeatureOptInConfirmDialog;
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@coss/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@coss/ui/components/dialog";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
interface FeatureOptInSuccessDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDismiss: () => void;
|
||||
onViewSettings: () => void;
|
||||
}
|
||||
|
||||
export function FeatureOptInSuccessDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onDismiss,
|
||||
onViewSettings,
|
||||
}: FeatureOptInSuccessDialogProps): ReactElement {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("feature_enabled_successfully")}</DialogTitle>
|
||||
<DialogDescription>{t("feature_enabled_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />} onClick={onDismiss}>
|
||||
{t("dismiss")}
|
||||
</DialogClose>
|
||||
<Button onClick={onViewSettings}>{t("view_settings")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default FeatureOptInSuccessDialog;
|
||||
@@ -101,7 +101,7 @@ function FeatureItem({
|
||||
"text-emphasis flex items-center text-sm font-medium",
|
||||
blockedWarning && "text-subtle line-through"
|
||||
)}>
|
||||
{t(config.titleI18nKey)}
|
||||
{t(config.i18n.name)}
|
||||
{blockedWarning && (
|
||||
<Tooltip side="top" content={blockedWarning}>
|
||||
<span>
|
||||
@@ -117,7 +117,7 @@ function FeatureItem({
|
||||
</Tooltip>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-subtle text-sm">{t(config.descriptionI18nKey)}</p>
|
||||
<p className="text-subtle text-sm">{t(config.i18n.description)}</p>
|
||||
</div>
|
||||
<ToggleGroup
|
||||
value={feature.currentState}
|
||||
|
||||
@@ -3,8 +3,16 @@ import type { OptInFeaturePolicy, OptInFeatureScope } from "./types";
|
||||
|
||||
export interface OptInFeatureConfig {
|
||||
slug: FeatureId;
|
||||
titleI18nKey: string;
|
||||
descriptionI18nKey: string;
|
||||
i18n: {
|
||||
title: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
bannerImage: {
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
policy: OptInFeaturePolicy;
|
||||
/** Scopes where this feature can be configured. Defaults to all scopes if not specified. */
|
||||
scope?: OptInFeatureScope[];
|
||||
@@ -21,8 +29,16 @@ export const OPT_IN_FEATURES: OptInFeatureConfig[] = [
|
||||
// Example - to be populated with actual features
|
||||
// {
|
||||
// slug: "bookings-v3",
|
||||
// titleI18nKey: "bookings_v3_title",
|
||||
// descriptionI18nKey: "bookings_v3_description",
|
||||
// i18n: {
|
||||
// title: "bookings_v3_title",
|
||||
// name: "bookings_v3_name",
|
||||
// description: "bookings_v3_description",
|
||||
// },
|
||||
// bannerImage: {
|
||||
// src: "/opt_in_banner_bookings_v3.png",
|
||||
// width: 548,
|
||||
// height: 348,
|
||||
// },
|
||||
// policy: "permissive",
|
||||
// scope: ["org", "team", "user"], // Optional: defaults to all scopes if not specified
|
||||
// },
|
||||
@@ -49,13 +65,19 @@ export function isOptInFeature(slug: string): slug is FeatureId {
|
||||
export const HAS_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.length > 0;
|
||||
|
||||
/** Whether there are opt-in features available for the user scope */
|
||||
export const HAS_USER_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.some((f) => !f.scope || f.scope.includes("user"));
|
||||
export const HAS_USER_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.some(
|
||||
(f) => !f.scope || f.scope.includes("user")
|
||||
);
|
||||
|
||||
/** Whether there are opt-in features available for the team scope */
|
||||
export const HAS_TEAM_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.some((f) => !f.scope || f.scope.includes("team"));
|
||||
export const HAS_TEAM_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.some(
|
||||
(f) => !f.scope || f.scope.includes("team")
|
||||
);
|
||||
|
||||
/** Whether there are opt-in features available for the org scope */
|
||||
export const HAS_ORG_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.some((f) => !f.scope || f.scope.includes("org"));
|
||||
export const HAS_ORG_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.some(
|
||||
(f) => !f.scope || f.scope.includes("org")
|
||||
);
|
||||
|
||||
/**
|
||||
* Get opt-in features that are available for a specific scope.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { applyAutoOptIn } from "./applyAutoOptIn";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { FeatureState } from "@calcom/features/flags/config";
|
||||
import { computeEffectiveStateAcrossTeams } from "./computeEffectiveState";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OptInFeaturePolicy } from "../types";
|
||||
import { computeEffectiveStateAcrossTeams } from "./computeEffectiveState";
|
||||
|
||||
/**
|
||||
* Feature Opt-In Effective State Computation
|
||||
@@ -63,9 +63,24 @@ import type { OptInFeaturePolicy } from "../types";
|
||||
describe("computeEffectiveStateAcrossTeams", () => {
|
||||
describe("Global Kill Switch", () => {
|
||||
it.each([
|
||||
{ policy: "permissive" as OptInFeaturePolicy, orgState: "enabled" as FeatureState, teamStates: ["enabled"] as FeatureState[], userState: "enabled" as FeatureState },
|
||||
{ policy: "strict" as OptInFeaturePolicy, orgState: "enabled" as FeatureState, teamStates: ["enabled"] as FeatureState[], userState: "enabled" as FeatureState },
|
||||
])("blocks feature when global is disabled (policy: $policy)", ({ policy, orgState, teamStates, userState }) => {
|
||||
{
|
||||
policy: "permissive" as OptInFeaturePolicy,
|
||||
orgState: "enabled" as FeatureState,
|
||||
teamStates: ["enabled"] as FeatureState[],
|
||||
userState: "enabled" as FeatureState,
|
||||
},
|
||||
{
|
||||
policy: "strict" as OptInFeaturePolicy,
|
||||
orgState: "enabled" as FeatureState,
|
||||
teamStates: ["enabled"] as FeatureState[],
|
||||
userState: "enabled" as FeatureState,
|
||||
},
|
||||
])("blocks feature when global is disabled (policy: $policy)", ({
|
||||
policy,
|
||||
orgState,
|
||||
teamStates,
|
||||
userState,
|
||||
}) => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: false,
|
||||
@@ -80,8 +95,16 @@ describe("computeEffectiveStateAcrossTeams", () => {
|
||||
|
||||
describe("Organization Level Blocking", () => {
|
||||
it.each([
|
||||
{ policy: "permissive" as OptInFeaturePolicy, teamStates: ["enabled"] as FeatureState[], userState: "enabled" as FeatureState },
|
||||
{ policy: "strict" as OptInFeaturePolicy, teamStates: ["enabled"] as FeatureState[], userState: "enabled" as FeatureState },
|
||||
{
|
||||
policy: "permissive" as OptInFeaturePolicy,
|
||||
teamStates: ["enabled"] as FeatureState[],
|
||||
userState: "enabled" as FeatureState,
|
||||
},
|
||||
{
|
||||
policy: "strict" as OptInFeaturePolicy,
|
||||
teamStates: ["enabled"] as FeatureState[],
|
||||
userState: "enabled" as FeatureState,
|
||||
},
|
||||
])("blocks feature when org is disabled (policy: $policy)", ({ policy, teamStates, userState }) => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getFeatureOptInService } from "@calcom/features/di/containers/FeatureOptInService";
|
||||
import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository";
|
||||
import type { FeatureId } from "@calcom/features/flags/config";
|
||||
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IFeatureOptInService } from "./IFeatureOptInService";
|
||||
|
||||
|
||||
@@ -1,32 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { FeatureState } from "@calcom/features/flags/config";
|
||||
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { ErrorWithCode } from "@calcom/lib/errors";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FeatureOptInService } from "./FeatureOptInService";
|
||||
|
||||
const mockIsFeatureAllowedForScope = vi.fn();
|
||||
const mockFindAllByUserId = vi.fn();
|
||||
|
||||
// Mock the OPT_IN_FEATURES config
|
||||
vi.mock("../config", () => {
|
||||
const mockFeatures = [
|
||||
{ slug: "test-feature-1", titleI18nKey: "test_feature_1", descriptionI18nKey: "test_feature_1_desc" },
|
||||
{ slug: "test-feature-2", titleI18nKey: "test_feature_2", descriptionI18nKey: "test_feature_2_desc" },
|
||||
{ slug: "org-only-feature", titleI18nKey: "org_only", descriptionI18nKey: "org_only_desc", scope: ["org"] },
|
||||
{ slug: "team-only-feature", titleI18nKey: "team_only", descriptionI18nKey: "team_only_desc", scope: ["team"] },
|
||||
{ slug: "user-only-feature", titleI18nKey: "user_only", descriptionI18nKey: "user_only_desc", scope: ["user"] },
|
||||
];
|
||||
const mockFeatures = [
|
||||
{
|
||||
slug: "test-feature-1",
|
||||
i18n: { title: "test_feature_1_title", name: "test_feature_1", description: "test_feature_1_desc" },
|
||||
policy: "permissive",
|
||||
},
|
||||
{
|
||||
slug: "test-feature-2",
|
||||
i18n: { title: "test_feature_2_title", name: "test_feature_2", description: "test_feature_2_desc" },
|
||||
policy: "permissive",
|
||||
},
|
||||
{
|
||||
slug: "org-only-feature",
|
||||
i18n: { title: "org_only_title", name: "org_only", description: "org_only_desc" },
|
||||
policy: "permissive",
|
||||
scope: ["org"],
|
||||
},
|
||||
{
|
||||
slug: "team-only-feature",
|
||||
i18n: { title: "team_only_title", name: "team_only", description: "team_only_desc" },
|
||||
policy: "permissive",
|
||||
scope: ["team"],
|
||||
},
|
||||
{
|
||||
slug: "user-only-feature",
|
||||
i18n: { title: "user_only_title", name: "user_only", description: "user_only_desc" },
|
||||
policy: "permissive",
|
||||
scope: ["user"],
|
||||
},
|
||||
{
|
||||
slug: "strict-feature",
|
||||
i18n: { title: "strict_feature_title", name: "strict_feature", description: "strict_feature_desc" },
|
||||
policy: "strict",
|
||||
},
|
||||
];
|
||||
return {
|
||||
OPT_IN_FEATURES: mockFeatures,
|
||||
isOptInFeature: (slug: string) => mockFeatures.some((feature) => feature.slug === slug),
|
||||
getOptInFeaturesForScope: () => mockFeatures,
|
||||
get isFeatureAllowedForScope() {
|
||||
return mockIsFeatureAllowedForScope;
|
||||
},
|
||||
getOptInFeatureConfig: (slug: string) => mockFeatures.find((f) => f.slug === slug),
|
||||
getOptInFeatureConfig: (slug: string) => mockFeatures.find((feature) => feature.slug === slug),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock MembershipRepository
|
||||
vi.mock("@calcom/features/membership/repositories/MembershipRepository", () => ({
|
||||
MembershipRepository: class {
|
||||
findAllByUserId(...args: unknown[]) {
|
||||
return mockFindAllByUserId(...args);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock PermissionCheckService - needs to be a class that can be instantiated
|
||||
let mockCheckPermission = vi.fn();
|
||||
vi.mock("@calcom/features/pbac/services/permission-check.service", () => ({
|
||||
PermissionCheckService: class {
|
||||
checkPermission(...args: unknown[]) {
|
||||
return mockCheckPermission(...args);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock TeamRepository - needs to be a class that can be instantiated
|
||||
let mockFindOwnedTeamsByUserId = vi.fn();
|
||||
vi.mock("@calcom/features/ee/teams/repositories/TeamRepository", () => ({
|
||||
TeamRepository: class {
|
||||
findOwnedTeamsByUserId(...args: unknown[]) {
|
||||
return mockFindOwnedTeamsByUserId(...args);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
prisma: {},
|
||||
}));
|
||||
|
||||
describe("FeatureOptInService", () => {
|
||||
let mockFeaturesRepository: {
|
||||
getAllFeatures: ReturnType<typeof vi.fn>;
|
||||
@@ -81,7 +145,14 @@ describe("FeatureOptInService", () => {
|
||||
// Verify that only the team ID was queried (no parent org)
|
||||
expect(mockFeaturesRepository.getTeamsFeatureStates).toHaveBeenCalledWith({
|
||||
teamIds: [1],
|
||||
featureIds: ["test-feature-1", "test-feature-2", "org-only-feature", "team-only-feature", "user-only-feature"],
|
||||
featureIds: [
|
||||
"test-feature-1",
|
||||
"test-feature-2",
|
||||
"org-only-feature",
|
||||
"team-only-feature",
|
||||
"user-only-feature",
|
||||
"strict-feature",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,14 +187,19 @@ describe("FeatureOptInService", () => {
|
||||
// Verify that both team ID and parent org ID were queried
|
||||
expect(mockFeaturesRepository.getTeamsFeatureStates).toHaveBeenCalledWith({
|
||||
teamIds: [1, 100],
|
||||
featureIds: ["test-feature-1", "test-feature-2", "org-only-feature", "team-only-feature", "user-only-feature"],
|
||||
featureIds: [
|
||||
"test-feature-1",
|
||||
"test-feature-2",
|
||||
"org-only-feature",
|
||||
"team-only-feature",
|
||||
"user-only-feature",
|
||||
"strict-feature",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return inherit for org state when parent org has no explicit state", async () => {
|
||||
mockFeaturesRepository.getAllFeatures.mockResolvedValue([
|
||||
{ slug: "test-feature-1", enabled: true },
|
||||
]);
|
||||
mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]);
|
||||
|
||||
// Team ID is 1, Parent Org ID is 100, but org has no explicit state
|
||||
mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({
|
||||
@@ -159,9 +235,7 @@ describe("FeatureOptInService", () => {
|
||||
});
|
||||
|
||||
it("should return inherit for team state when team has no explicit state", async () => {
|
||||
mockFeaturesRepository.getAllFeatures.mockResolvedValue([
|
||||
{ slug: "test-feature-1", enabled: true },
|
||||
]);
|
||||
mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]);
|
||||
|
||||
mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({
|
||||
"test-feature-1": {},
|
||||
@@ -179,9 +253,7 @@ describe("FeatureOptInService", () => {
|
||||
});
|
||||
|
||||
it("should handle null parentOrgId the same as undefined", async () => {
|
||||
mockFeaturesRepository.getAllFeatures.mockResolvedValue([
|
||||
{ slug: "test-feature-1", enabled: true },
|
||||
]);
|
||||
mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]);
|
||||
|
||||
mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({
|
||||
"test-feature-1": { 1: "enabled" as FeatureState },
|
||||
@@ -195,7 +267,14 @@ describe("FeatureOptInService", () => {
|
||||
// Verify that only the team ID was queried (no parent org)
|
||||
expect(mockFeaturesRepository.getTeamsFeatureStates).toHaveBeenCalledWith({
|
||||
teamIds: [1],
|
||||
featureIds: ["test-feature-1", "test-feature-2", "org-only-feature", "team-only-feature", "user-only-feature"],
|
||||
featureIds: [
|
||||
"test-feature-1",
|
||||
"test-feature-2",
|
||||
"org-only-feature",
|
||||
"team-only-feature",
|
||||
"user-only-feature",
|
||||
"strict-feature",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -405,4 +484,236 @@ describe("FeatureOptInService", () => {
|
||||
expect(mockFeaturesRepository.setTeamFeatureState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkFeatureOptInEligibility", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
mockFindAllByUserId.mockReset();
|
||||
mockCheckPermission = vi.fn();
|
||||
mockFindOwnedTeamsByUserId = vi.fn();
|
||||
|
||||
mockFeaturesRepository = {
|
||||
getAllFeatures: vi.fn(),
|
||||
getTeamsFeatureStates: vi.fn(),
|
||||
getUserFeatureStates: vi.fn().mockResolvedValue({}),
|
||||
getUserAutoOptIn: vi.fn().mockResolvedValue(false),
|
||||
getTeamsAutoOptIn: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
|
||||
service = new FeatureOptInService(mockFeaturesRepository as unknown as FeaturesRepository);
|
||||
});
|
||||
|
||||
it("should return invalid_feature for non-opt-in features", async () => {
|
||||
const result = await service.checkFeatureOptInEligibility({
|
||||
userId: 1,
|
||||
featureId: "non-existent-feature",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "invalid_feature",
|
||||
canOptIn: false,
|
||||
userRoleContext: null,
|
||||
blockingReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return feature_disabled when feature is globally disabled", async () => {
|
||||
mockFindAllByUserId.mockResolvedValue([]);
|
||||
mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: false }]);
|
||||
mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({});
|
||||
|
||||
const result = await service.checkFeatureOptInEligibility({
|
||||
userId: 1,
|
||||
featureId: "test-feature-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "feature_disabled",
|
||||
canOptIn: false,
|
||||
userRoleContext: null,
|
||||
blockingReason: "feature_global_disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return already_enabled when feature is already enabled for user", async () => {
|
||||
mockFindAllByUserId.mockResolvedValue([]);
|
||||
mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]);
|
||||
mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({});
|
||||
mockFeaturesRepository.getUserFeatureStates.mockResolvedValue({
|
||||
"test-feature-1": "enabled" as FeatureState,
|
||||
});
|
||||
mockFindOwnedTeamsByUserId.mockResolvedValue([]);
|
||||
|
||||
const result = await service.checkFeatureOptInEligibility({
|
||||
userId: 1,
|
||||
featureId: "test-feature-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "already_enabled",
|
||||
canOptIn: false,
|
||||
userRoleContext: null,
|
||||
blockingReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return blocked when feature is blocked by org", async () => {
|
||||
mockFindAllByUserId.mockResolvedValue([
|
||||
{ teamId: 100, role: "MEMBER", team: { id: 100, isOrganization: true, parentId: null } },
|
||||
]);
|
||||
mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]);
|
||||
mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({
|
||||
"test-feature-1": { 100: "disabled" as FeatureState },
|
||||
});
|
||||
mockFindOwnedTeamsByUserId.mockResolvedValue([]);
|
||||
|
||||
const result = await service.checkFeatureOptInEligibility({
|
||||
userId: 1,
|
||||
featureId: "test-feature-1",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("blocked");
|
||||
expect(result.canOptIn).toBe(false);
|
||||
expect(result.blockingReason).toBe("feature_org_disabled");
|
||||
expect(result.userRoleContext).toMatchObject({
|
||||
orgId: 100,
|
||||
adminTeamIds: [],
|
||||
adminTeamNames: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return can_opt_in when user can opt in", async () => {
|
||||
mockFindAllByUserId.mockResolvedValue([
|
||||
{ teamId: 1, role: "MEMBER", team: { id: 1, isOrganization: false, parentId: null } },
|
||||
]);
|
||||
mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]);
|
||||
mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({});
|
||||
mockFindOwnedTeamsByUserId.mockResolvedValue([]);
|
||||
|
||||
const result = await service.checkFeatureOptInEligibility({
|
||||
userId: 1,
|
||||
featureId: "test-feature-1",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("can_opt_in");
|
||||
expect(result.canOptIn).toBe(true);
|
||||
expect(result.userRoleContext).toMatchObject({
|
||||
orgId: null,
|
||||
adminTeamIds: [],
|
||||
adminTeamNames: [],
|
||||
});
|
||||
expect(result.blockingReason).toBeNull();
|
||||
});
|
||||
|
||||
it("should include admin teams in user role context", async () => {
|
||||
mockFindAllByUserId.mockResolvedValue([
|
||||
{ teamId: 1, role: "ADMIN", team: { id: 1, isOrganization: false, parentId: null } },
|
||||
{ teamId: 2, role: "MEMBER", team: { id: 2, isOrganization: false, parentId: null } },
|
||||
]);
|
||||
mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]);
|
||||
mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({});
|
||||
mockFindOwnedTeamsByUserId.mockResolvedValue([{ id: 1, name: "Team 1", isOrganization: false }]);
|
||||
|
||||
const result = await service.checkFeatureOptInEligibility({
|
||||
userId: 1,
|
||||
featureId: "test-feature-1",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("can_opt_in");
|
||||
expect(result.userRoleContext?.adminTeamIds).toEqual([1]);
|
||||
expect(result.userRoleContext?.adminTeamNames).toEqual([{ id: 1, name: "Team 1" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkFeatureOptInEligibility - simulation logic", () => {
|
||||
let mockMembershipRepository: { findAllByUserId: ReturnType<typeof vi.fn> };
|
||||
let mockPermissionCheckService: { checkPermission: ReturnType<typeof vi.fn> };
|
||||
let mockTeamRepository: { findOwnedTeamsByUserId: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockMembershipRepository = {
|
||||
findAllByUserId: vi.fn(),
|
||||
};
|
||||
mockPermissionCheckService = {
|
||||
checkPermission: vi.fn(),
|
||||
};
|
||||
mockTeamRepository = {
|
||||
findOwnedTeamsByUserId: vi.fn(),
|
||||
};
|
||||
|
||||
vi.doMock("@calcom/features/membership/repositories/MembershipRepository", () => ({
|
||||
MembershipRepository: class {
|
||||
findAllByUserId(...args: unknown[]) {
|
||||
return mockMembershipRepository.findAllByUserId(...args);
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.doMock("@calcom/features/pbac/services/permission-check.service", () => ({
|
||||
PermissionCheckService: vi.fn(() => mockPermissionCheckService),
|
||||
}));
|
||||
vi.doMock("@calcom/features/ee/teams/repositories/TeamRepository", () => ({
|
||||
TeamRepository: vi.fn(() => mockTeamRepository),
|
||||
}));
|
||||
});
|
||||
|
||||
it("should return blocked when strict policy feature would not be enabled even after user opts in", async () => {
|
||||
const { computeEffectiveStateAcrossTeams } = await import("../lib/computeEffectiveState");
|
||||
|
||||
const result = computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["inherit"],
|
||||
userState: "enabled",
|
||||
policy: "strict",
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.reason).toBe("feature_user_only_not_allowed");
|
||||
});
|
||||
|
||||
it("should return enabled when permissive policy feature would be enabled after user opts in", async () => {
|
||||
const { computeEffectiveStateAcrossTeams } = await import("../lib/computeEffectiveState");
|
||||
|
||||
const result = computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["inherit"],
|
||||
userState: "enabled",
|
||||
policy: "permissive",
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.reason).toBe("feature_enabled");
|
||||
});
|
||||
|
||||
it("should return enabled when strict policy feature has org enablement and user opts in", async () => {
|
||||
const { computeEffectiveStateAcrossTeams } = await import("../lib/computeEffectiveState");
|
||||
|
||||
const result = computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
teamStates: ["inherit"],
|
||||
userState: "enabled",
|
||||
policy: "strict",
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.reason).toBe("feature_enabled");
|
||||
});
|
||||
|
||||
it("should return blocked when strict policy feature has any team disabled", async () => {
|
||||
const { computeEffectiveStateAcrossTeams } = await import("../lib/computeEffectiveState");
|
||||
|
||||
const result = computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
teamStates: ["disabled", "inherit"],
|
||||
userState: "enabled",
|
||||
policy: "strict",
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.reason).toBe("feature_any_team_disabled");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,28 @@
|
||||
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
||||
import type { FeatureId, FeatureState } from "@calcom/features/flags/config";
|
||||
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { ErrorWithCode } from "@calcom/lib/errors";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { getOptInFeatureConfig, getOptInFeaturesForScope, isFeatureAllowedForScope } from "../config";
|
||||
import {
|
||||
getOptInFeatureConfig,
|
||||
getOptInFeaturesForScope,
|
||||
isFeatureAllowedForScope,
|
||||
isOptInFeature,
|
||||
} from "../config";
|
||||
import { applyAutoOptIn } from "../lib/applyAutoOptIn";
|
||||
import { computeEffectiveStateAcrossTeams } from "../lib/computeEffectiveState";
|
||||
import type { OptInFeaturePolicy, OptInFeatureScope } from "../types";
|
||||
import type {
|
||||
EffectiveStateReason,
|
||||
FeatureOptInEligibilityResult,
|
||||
IFeatureOptInService,
|
||||
ResolvedFeatureState,
|
||||
UserRoleContext,
|
||||
} from "./IFeatureOptInService";
|
||||
|
||||
type ListFeaturesForUserResult = {
|
||||
@@ -33,6 +45,13 @@ type ListFeaturesForTeamResult = {
|
||||
orgState: FeatureState;
|
||||
};
|
||||
|
||||
type FeatureOptInEligibilityStatus =
|
||||
| "invalid_feature"
|
||||
| "feature_disabled"
|
||||
| "already_enabled"
|
||||
| "blocked"
|
||||
| "can_opt_in";
|
||||
|
||||
function getOrgState(orgId: number | null, teamStatesById: Record<number, FeatureState>): FeatureState {
|
||||
if (orgId !== null) {
|
||||
return teamStatesById[orgId] ?? "inherit";
|
||||
@@ -100,7 +119,10 @@ export class FeatureOptInService implements IFeatureOptInService {
|
||||
teamIds: number[];
|
||||
featureIds: FeatureId[];
|
||||
}): Promise<Record<string, ResolvedFeatureState>> {
|
||||
const allTeamIds = orgId !== null ? [orgId, ...teamIds] : teamIds;
|
||||
let allTeamIds = teamIds;
|
||||
if (orgId !== null) {
|
||||
allTeamIds = [orgId, ...teamIds];
|
||||
}
|
||||
|
||||
const [allFeatures, allTeamStates, userStates, userAutoOptIn, teamsAutoOptIn] = await Promise.all([
|
||||
this.featuresRepository.getAllFeatures(),
|
||||
@@ -242,6 +264,167 @@ export class FeatureOptInService implements IFeatureOptInService {
|
||||
return results.filter((result) => result.globalEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is eligible to see the feature opt-in banner.
|
||||
* Uses PBAC to determine user's role context (org admin, team admin permissions).
|
||||
*/
|
||||
async checkFeatureOptInEligibility(input: {
|
||||
userId: number;
|
||||
featureId: string;
|
||||
}): Promise<FeatureOptInEligibilityResult> {
|
||||
const { userId, featureId } = input;
|
||||
|
||||
if (!isOptInFeature(featureId)) {
|
||||
return {
|
||||
status: "invalid_feature",
|
||||
canOptIn: false,
|
||||
userRoleContext: null,
|
||||
blockingReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
const { orgId, teamIds } = await this.getUserOrgAndTeamIds(userId);
|
||||
const resolvedStates = await this.resolveFeatureStatesAcrossTeams({
|
||||
userId,
|
||||
orgId,
|
||||
teamIds,
|
||||
featureIds: [featureId],
|
||||
});
|
||||
|
||||
const featureState = resolvedStates[featureId];
|
||||
if (!featureState) {
|
||||
return {
|
||||
status: "invalid_feature",
|
||||
canOptIn: false,
|
||||
userRoleContext: null,
|
||||
blockingReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!featureState.globalEnabled) {
|
||||
return {
|
||||
status: "feature_disabled",
|
||||
canOptIn: false,
|
||||
userRoleContext: null,
|
||||
blockingReason: "feature_global_disabled",
|
||||
};
|
||||
}
|
||||
|
||||
if (featureState.effectiveEnabled) {
|
||||
return {
|
||||
status: "already_enabled",
|
||||
canOptIn: false,
|
||||
userRoleContext: null,
|
||||
blockingReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
const userRoleContext = await this.getUserRoleContext(userId, orgId, teamIds);
|
||||
|
||||
const blockingReasons = [
|
||||
"feature_org_disabled",
|
||||
"feature_all_teams_disabled",
|
||||
"feature_any_team_disabled",
|
||||
"feature_user_only_not_allowed",
|
||||
];
|
||||
|
||||
if (blockingReasons.includes(featureState.effectiveReason)) {
|
||||
return {
|
||||
status: "blocked",
|
||||
canOptIn: false,
|
||||
userRoleContext,
|
||||
blockingReason: featureState.effectiveReason,
|
||||
};
|
||||
}
|
||||
|
||||
// Simulate what would happen if user opts in
|
||||
const featureConfig = getOptInFeatureConfig(featureId);
|
||||
if (!featureConfig) {
|
||||
return {
|
||||
status: "blocked",
|
||||
canOptIn: false,
|
||||
userRoleContext,
|
||||
blockingReason: "feature_config_not_found",
|
||||
};
|
||||
}
|
||||
const policy: OptInFeaturePolicy = featureConfig.policy ?? "permissive";
|
||||
|
||||
const simulatedResult = computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: featureState.globalEnabled,
|
||||
orgState: featureState.orgState,
|
||||
teamStates: featureState.teamStates,
|
||||
userState: "enabled",
|
||||
policy,
|
||||
});
|
||||
|
||||
// For strict policy features, user opt-in alone won't enable the feature if org/team hasn't explicitly enabled it.
|
||||
// E.g., strict policy + org "inherit" + team "inherit" + user "enabled" → feature still not enabled.
|
||||
if (!simulatedResult.enabled) {
|
||||
return {
|
||||
status: "blocked",
|
||||
canOptIn: false,
|
||||
userRoleContext,
|
||||
blockingReason: simulatedResult.reason,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "can_opt_in",
|
||||
canOptIn: true,
|
||||
userRoleContext,
|
||||
blockingReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async getUserOrgAndTeamIds(userId: number): Promise<{ orgId: number | null; teamIds: number[] }> {
|
||||
const membershipRepository = new MembershipRepository(prisma);
|
||||
const memberships = await membershipRepository.findAllByUserId({
|
||||
userId,
|
||||
filters: { accepted: true },
|
||||
});
|
||||
|
||||
let orgId: number | null = null;
|
||||
const teamIds: number[] = [];
|
||||
|
||||
for (const membership of memberships) {
|
||||
if (membership.team.isOrganization) {
|
||||
orgId = membership.teamId;
|
||||
} else {
|
||||
teamIds.push(membership.teamId);
|
||||
}
|
||||
}
|
||||
|
||||
return { orgId, teamIds };
|
||||
}
|
||||
|
||||
private async getUserRoleContext(
|
||||
userId: number,
|
||||
orgId: number | null,
|
||||
_teamIds: number[]
|
||||
): Promise<UserRoleContext> {
|
||||
const permissionService = new PermissionCheckService();
|
||||
const fallbackRoles = [MembershipRole.OWNER, MembershipRole.ADMIN];
|
||||
|
||||
let isOrgAdmin = false;
|
||||
if (orgId !== null) {
|
||||
isOrgAdmin = await permissionService.checkPermission({
|
||||
userId,
|
||||
teamId: orgId,
|
||||
permission: "organization.update",
|
||||
fallbackRoles,
|
||||
});
|
||||
}
|
||||
|
||||
const teamRepository = new TeamRepository(prisma);
|
||||
const adminTeams = await teamRepository.findOwnedTeamsByUserId({ userId });
|
||||
|
||||
const nonOrgAdminTeams = adminTeams.filter((team) => !team.isOrganization);
|
||||
const adminTeamIds = nonOrgAdminTeams.map((team) => team.id);
|
||||
const adminTeamNames = nonOrgAdminTeams.map((team) => ({ id: team.id, name: team.name }));
|
||||
|
||||
return { isOrgAdmin, orgId, adminTeamIds, adminTeamNames };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user's feature state.
|
||||
* Delegates to FeaturesRepository.setUserFeatureState.
|
||||
@@ -281,7 +464,13 @@ export class FeatureOptInService implements IFeatureOptInService {
|
||||
*/
|
||||
async setTeamFeatureState(
|
||||
input:
|
||||
| { teamId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: number; scope?: OptInFeatureScope }
|
||||
| {
|
||||
teamId: number;
|
||||
featureId: FeatureId;
|
||||
state: "enabled" | "disabled";
|
||||
assignedBy: number;
|
||||
scope?: OptInFeatureScope;
|
||||
}
|
||||
| { teamId: number; featureId: FeatureId; state: "inherit"; scope?: OptInFeatureScope }
|
||||
): Promise<void> {
|
||||
const { teamId, featureId, state } = input;
|
||||
@@ -307,3 +496,6 @@ export class FeatureOptInService implements IFeatureOptInService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type { FeatureOptInEligibilityStatus };
|
||||
export type { UserRoleContext, FeatureOptInEligibilityResult } from "./IFeatureOptInService";
|
||||
@@ -5,6 +5,45 @@ import type { OptInFeatureScope } from "../types";
|
||||
|
||||
export type { EffectiveStateReason };
|
||||
|
||||
export type UserRoleContext = {
|
||||
isOrgAdmin: boolean;
|
||||
orgId: number | null;
|
||||
adminTeamIds: number[];
|
||||
adminTeamNames: { id: number; name: string }[];
|
||||
};
|
||||
|
||||
export type FeatureOptInEligibilityResult =
|
||||
| {
|
||||
status: "invalid_feature";
|
||||
canOptIn: false;
|
||||
userRoleContext: null;
|
||||
blockingReason: null;
|
||||
}
|
||||
| {
|
||||
status: "feature_disabled";
|
||||
canOptIn: false;
|
||||
userRoleContext: null;
|
||||
blockingReason: string;
|
||||
}
|
||||
| {
|
||||
status: "already_enabled";
|
||||
canOptIn: false;
|
||||
userRoleContext: null;
|
||||
blockingReason: null;
|
||||
}
|
||||
| {
|
||||
status: "blocked";
|
||||
canOptIn: false;
|
||||
userRoleContext: UserRoleContext;
|
||||
blockingReason: string;
|
||||
}
|
||||
| {
|
||||
status: "can_opt_in";
|
||||
canOptIn: true;
|
||||
userRoleContext: UserRoleContext;
|
||||
blockingReason: null;
|
||||
};
|
||||
|
||||
export type ResolvedFeatureState = {
|
||||
featureId: FeatureId;
|
||||
globalEnabled: boolean;
|
||||
@@ -44,7 +83,17 @@ export interface IFeatureOptInService {
|
||||
): Promise<void>;
|
||||
setTeamFeatureState(
|
||||
input:
|
||||
| { teamId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: number; scope?: OptInFeatureScope }
|
||||
| {
|
||||
teamId: number;
|
||||
featureId: FeatureId;
|
||||
state: "enabled" | "disabled";
|
||||
assignedBy: number;
|
||||
scope?: OptInFeatureScope;
|
||||
}
|
||||
| { teamId: number; featureId: FeatureId; state: "inherit"; scope?: OptInFeatureScope }
|
||||
): Promise<void>;
|
||||
checkFeatureOptInEligibility(input: {
|
||||
userId: number;
|
||||
featureId: string;
|
||||
}): Promise<FeatureOptInEligibilityResult>;
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"@calcom/lib": "workspace:*",
|
||||
"@calcom/trpc": "workspace:*",
|
||||
"@calcom/ui": "workspace:*",
|
||||
"@coss/ui": "workspace:*",
|
||||
"@evyweb/ioctopus": "1.2.0",
|
||||
"@tanstack/react-table": "8.20.6",
|
||||
"@tanstack/react-virtual": "3.10.9",
|
||||
|
||||
@@ -1,30 +1,28 @@
|
||||
import type { ZodEnum } from "zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getFeatureOptInService } from "@calcom/features/di/containers/FeatureOptInService";
|
||||
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
||||
import { isOptInFeature } from "@calcom/features/feature-opt-in/config";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type { ZodEnum } from "zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import authedProcedure from "../../../procedures/authedProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
import { createOrgPbacProcedure, createTeamPbacProcedure } from "../../../procedures/pbacProcedures";
|
||||
import { router } from "../../../trpc";
|
||||
|
||||
const featureStateSchema: ZodEnum<["enabled", "disabled", "inherit"]> = z.enum(["enabled", "disabled", "inherit"]);
|
||||
const featureStateSchema: ZodEnum<["enabled", "disabled", "inherit"]> = z.enum([
|
||||
"enabled",
|
||||
"disabled",
|
||||
"inherit",
|
||||
]);
|
||||
|
||||
const featureOptInService = getFeatureOptInService();
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const teamRepository = new TeamRepository(prisma);
|
||||
const membershipRepository = new MembershipRepository(prisma);
|
||||
const featureOptInService: ReturnType<typeof getFeatureOptInService> = getFeatureOptInService();
|
||||
const featuresRepository: FeaturesRepository = new FeaturesRepository(prisma);
|
||||
const teamRepository: TeamRepository = new TeamRepository(prisma);
|
||||
const membershipRepository: MembershipRepository = new MembershipRepository(prisma);
|
||||
|
||||
/**
|
||||
* Helper to get user's org and team IDs from their memberships.
|
||||
* Returns orgId (if user belongs to an org) and teamIds (non-org teams).
|
||||
*/
|
||||
async function getUserOrgAndTeamIds(userId: number): Promise<{ orgId: number | null; teamIds: number[] }> {
|
||||
const memberships = await membershipRepository.findAllByUserId({
|
||||
userId,
|
||||
@@ -83,6 +81,19 @@ export const featureOptInRouter = router({
|
||||
return featureOptInService.listFeaturesForTeam({ teamId: ctx.organizationId, scope: "org" });
|
||||
}),
|
||||
|
||||
checkFeatureOptInEligibility: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
featureId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return featureOptInService.checkFeatureOptInEligibility({
|
||||
userId: ctx.user.id,
|
||||
featureId: input.featureId,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Set user's feature state.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user