diff --git a/apps/web/modules/bookings/views/bookings-view.tsx b/apps/web/modules/bookings/views/bookings-view.tsx index f140409997..2720c2c32b 100644 --- a/apps/web/modules/bookings/views/bookings-view.tsx +++ b/apps/web/modules/bookings/views/bookings-view.tsx @@ -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} /> )} + ); } diff --git a/apps/web/modules/ee/organizations/features-view.tsx b/apps/web/modules/ee/organizations/features-view.tsx index 0f7ae69f65..aa12f18722 100644 --- a/apps/web/modules/ee/organizations/features-view.tsx +++ b/apps/web/modules/ee/organizations/features-view.tsx @@ -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; diff --git a/apps/web/modules/feature-opt-in/hooks/index.ts b/apps/web/modules/feature-opt-in/hooks/index.ts deleted file mode 100644 index 299c32db4c..0000000000 --- a/apps/web/modules/feature-opt-in/hooks/index.ts +++ /dev/null @@ -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"; diff --git a/apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.ts b/apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.ts new file mode 100644 index 0000000000..7f9a8a4e83 --- /dev/null +++ b/apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.ts @@ -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; + +type UserRoleContext = { + isOrgAdmin: boolean; + orgId: number | null; + adminTeamIds: number[]; + adminTeamNames: { id: number; name: string }[]; +}; + +export type FeatureOptInMutations = { + setUserState: (params: { slug: string; state: "enabled" }) => Promise; + setTeamState: (params: { teamId: number; slug: string; state: "enabled" }) => Promise; + setOrganizationState: (params: { slug: string; state: "enabled" }) => Promise; + setUserAutoOptIn: (params: { autoOptIn: boolean }) => Promise; + setTeamAutoOptIn: (params: { teamId: number; autoOptIn: boolean }) => Promise; + setOrganizationAutoOptIn: (params: { autoOptIn: boolean }) => Promise; + 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, + }; +} diff --git a/apps/web/modules/feature-opt-in/hooks/useOrganizationFeatureOptIn.ts b/apps/web/modules/feature-opt-in/hooks/useOrganizationFeatureOptIn.ts index 21d834cf8a..fd7a916c11 100644 --- a/apps/web/modules/feature-opt-in/hooks/useOrganizationFeatureOptIn.ts +++ b/apps/web/modules/feature-opt-in/hooks/useOrganizationFeatureOptIn.ts @@ -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 { diff --git a/apps/web/modules/feature-opt-in/hooks/useTeamFeatureOptIn.ts b/apps/web/modules/feature-opt-in/hooks/useTeamFeatureOptIn.ts index afe980853b..cc4650ba67 100644 --- a/apps/web/modules/feature-opt-in/hooks/useTeamFeatureOptIn.ts +++ b/apps/web/modules/feature-opt-in/hooks/useTeamFeatureOptIn.ts @@ -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); diff --git a/apps/web/modules/feature-opt-in/hooks/useUserFeatureOptIn.ts b/apps/web/modules/feature-opt-in/hooks/useUserFeatureOptIn.ts index 70ccc5bde7..7f899d3b84 100644 --- a/apps/web/modules/feature-opt-in/hooks/useUserFeatureOptIn.ts +++ b/apps/web/modules/feature-opt-in/hooks/useUserFeatureOptIn.ts @@ -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); diff --git a/apps/web/modules/settings/my-account/features-view.tsx b/apps/web/modules/settings/my-account/features-view.tsx index 9c72e8722a..117a86f32a 100644 --- a/apps/web/modules/settings/my-account/features-view.tsx +++ b/apps/web/modules/settings/my-account/features-view.tsx @@ -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(); diff --git a/apps/web/modules/settings/teams/[id]/features-view.tsx b/apps/web/modules/settings/teams/[id]/features-view.tsx index 51e1ae0da4..c8fb5df99e 100644 --- a/apps/web/modules/settings/teams/[id]/features-view.tsx +++ b/apps/web/modules/settings/teams/[id]/features-view.tsx @@ -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; diff --git a/apps/web/public/opt_in_banner_bookings_v3.png b/apps/web/public/opt_in_banner_bookings_v3.png new file mode 100644 index 0000000000..6607402b27 Binary files /dev/null and b/apps/web/public/opt_in_banner_bookings_v3.png differ diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 3655fad31b..c700e413d6 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" diff --git a/packages/coss-ui/src/styles/globals.css b/packages/coss-ui/src/styles/globals.css index ff35dd239b..e8a9229916 100644 --- a/packages/coss-ui/src/styles/globals.css +++ b/packages/coss-ui/src/styles/globals.css @@ -177,3 +177,7 @@ @apply border-border outline-ring/50; } } + +.backdrop-blur-sm { + @apply backdrop-blur-none bg-neutral-800/70; +} diff --git a/packages/features/feature-opt-in/FEATURE_OPT_IN_BANNER_PLAN.md b/packages/features/feature-opt-in/FEATURE_OPT_IN_BANNER_PLAN.md new file mode 100644 index 0000000000..d8a6eceab4 --- /dev/null +++ b/packages/features/feature-opt-in/FEATURE_OPT_IN_BANNER_PLAN.md @@ -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 && }` + +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 diff --git a/packages/features/feature-opt-in/components/FeatureOptInBanner.tsx b/packages/features/feature-opt-in/components/FeatureOptInBanner.tsx new file mode 100644 index 0000000000..b24e371d3c --- /dev/null +++ b/packages/features/feature-opt-in/components/FeatureOptInBanner.tsx @@ -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 ( +
+
+

{t(featureConfig.i18n.title)}

+

{t(featureConfig.i18n.description)}

+ + + + +
+ +
+
+ {t(featureConfig.i18n.title)} +
+
+
+ ); +} + +export default FeatureOptInBanner; diff --git a/packages/features/feature-opt-in/components/FeatureOptInBannerWrapper.tsx b/packages/features/feature-opt-in/components/FeatureOptInBannerWrapper.tsx new file mode 100644 index 0000000000..eac60dc766 --- /dev/null +++ b/packages/features/feature-opt-in/components/FeatureOptInBannerWrapper.tsx @@ -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 && ( + + )} + {state.userRoleContext && ( + + )} + + ); +} + +export default FeatureOptInBannerWrapper; diff --git a/packages/features/feature-opt-in/components/FeatureOptInConfirmDialog.tsx b/packages/features/feature-opt-in/components/FeatureOptInConfirmDialog.tsx new file mode 100644 index 0000000000..8b080426fc --- /dev/null +++ b/packages/features/feature-opt-in/components/FeatureOptInConfirmDialog.tsx @@ -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; + setTeamState: (params: { teamId: number; slug: string; state: "enabled" }) => Promise; + setOrganizationState: (params: { slug: string; state: "enabled" }) => Promise; + setUserAutoOptIn: (params: { autoOptIn: boolean }) => Promise; + setTeamAutoOptIn: (params: { teamId: number; autoOptIn: boolean }) => Promise; + setOrganizationAutoOptIn: (params: { autoOptIn: boolean }) => Promise; + 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([]); + 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 => { + setIsSubmitting(true); + try { + const promises: Promise[] = []; + + 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 ( + + ); + } + + return ( + + + + {t(featureConfig.i18n.name)} + {t(featureConfig.i18n.description)} + + +
+ {showSelector && ( +
+ + + + }> + {getSelectedText()} + + + handleUserChange(checked)}> + + + {t("just_for_me")} + + + + {canEnableForOrg && ( + <> + + handleOrgChange(checked)}> + + + {t("entire_organization")} + + + + )} + + {canEnableForTeams && adminTeamNames.length > 0 && ( + <> + + {adminTeamNames.map((team) => ( + handleTeamChange(team.id, checked)}> + + + {team.name} + + + ))} + + )} + + +
+ )} + + +
+
+ + } onClick={resetAndClose} disabled={isSubmitting}> + {t("cancel")} + + + +
+
+ ); +} + +export default FeatureOptInConfirmDialog; diff --git a/packages/features/feature-opt-in/components/FeatureOptInSuccessDialog.tsx b/packages/features/feature-opt-in/components/FeatureOptInSuccessDialog.tsx new file mode 100644 index 0000000000..593ee7d074 --- /dev/null +++ b/packages/features/feature-opt-in/components/FeatureOptInSuccessDialog.tsx @@ -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 ( + + + + {t("feature_enabled_successfully")} + {t("feature_enabled_description")} + + + } onClick={onDismiss}> + {t("dismiss")} + + + + + + ); +} + +export default FeatureOptInSuccessDialog; diff --git a/packages/features/feature-opt-in/components/FeaturesSettings.tsx b/packages/features/feature-opt-in/components/FeaturesSettings.tsx index c73846a45a..3e5e4eb983 100644 --- a/packages/features/feature-opt-in/components/FeaturesSettings.tsx +++ b/packages/features/feature-opt-in/components/FeaturesSettings.tsx @@ -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 && ( @@ -117,7 +117,7 @@ function FeatureItem({ )} -

{t(config.descriptionI18nKey)}

+

{t(config.i18n.description)}

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. diff --git a/packages/features/feature-opt-in/lib/applyAutoOptIn.test.ts b/packages/features/feature-opt-in/lib/applyAutoOptIn.test.ts index ceb86c30bf..b6ea26264e 100644 --- a/packages/features/feature-opt-in/lib/applyAutoOptIn.test.ts +++ b/packages/features/feature-opt-in/lib/applyAutoOptIn.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { applyAutoOptIn } from "./applyAutoOptIn"; diff --git a/packages/features/feature-opt-in/lib/computeEffectiveState.test.ts b/packages/features/feature-opt-in/lib/computeEffectiveState.test.ts index 758abcb702..153acf647f 100644 --- a/packages/features/feature-opt-in/lib/computeEffectiveState.test.ts +++ b/packages/features/feature-opt-in/lib/computeEffectiveState.test.ts @@ -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({ diff --git a/packages/features/feature-opt-in/services/FeatureOptInService.integration-test.ts b/packages/features/feature-opt-in/services/FeatureOptInService.integration-test.ts index fed1c101ea..df39304156 100644 --- a/packages/features/feature-opt-in/services/FeatureOptInService.integration-test.ts +++ b/packages/features/feature-opt-in/services/FeatureOptInService.integration-test.ts @@ -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"; diff --git a/packages/features/feature-opt-in/services/FeatureOptInService.test.ts b/packages/features/feature-opt-in/services/FeatureOptInService.test.ts index a5e0ee489f..95ebb33e3b 100644 --- a/packages/features/feature-opt-in/services/FeatureOptInService.test.ts +++ b/packages/features/feature-opt-in/services/FeatureOptInService.test.ts @@ -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; @@ -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 }; + let mockPermissionCheckService: { checkPermission: ReturnType }; + let mockTeamRepository: { findOwnedTeamsByUserId: ReturnType }; + + 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"); + }); + }); }); diff --git a/packages/features/feature-opt-in/services/FeatureOptInService.ts b/packages/features/feature-opt-in/services/FeatureOptInService.ts index 67d714a069..c33ed1ee47 100644 --- a/packages/features/feature-opt-in/services/FeatureOptInService.ts +++ b/packages/features/feature-opt-in/services/FeatureOptInService.ts @@ -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): FeatureState { if (orgId !== null) { return teamStatesById[orgId] ?? "inherit"; @@ -100,7 +119,10 @@ export class FeatureOptInService implements IFeatureOptInService { teamIds: number[]; featureIds: FeatureId[]; }): Promise> { - 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 { + 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 { + 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 { const { teamId, featureId, state } = input; @@ -307,3 +496,6 @@ export class FeatureOptInService implements IFeatureOptInService { } } } + +export type { FeatureOptInEligibilityStatus }; +export type { UserRoleContext, FeatureOptInEligibilityResult } from "./IFeatureOptInService"; diff --git a/packages/features/feature-opt-in/services/IFeatureOptInService.ts b/packages/features/feature-opt-in/services/IFeatureOptInService.ts index e75b8f08f5..97e419006f 100644 --- a/packages/features/feature-opt-in/services/IFeatureOptInService.ts +++ b/packages/features/feature-opt-in/services/IFeatureOptInService.ts @@ -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; 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; + checkFeatureOptInEligibility(input: { + userId: number; + featureId: string; + }): Promise; } diff --git a/packages/features/package.json b/packages/features/package.json index 268d7d9e29..1e9cbf29b9 100644 --- a/packages/features/package.json +++ b/packages/features/package.json @@ -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", diff --git a/packages/trpc/server/routers/viewer/featureOptIn/_router.ts b/packages/trpc/server/routers/viewer/featureOptIn/_router.ts index 53a34c6d11..884c3c6cef 100644 --- a/packages/trpc/server/routers/viewer/featureOptIn/_router.ts +++ b/packages/trpc/server/routers/viewer/featureOptIn/_router.ts @@ -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 = 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. */ diff --git a/yarn.lock b/yarn.lock index 9eec504e09..60c14f4084 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2602,6 +2602,7 @@ __metadata: "@calcom/testing": "workspace:*" "@calcom/trpc": "workspace:*" "@calcom/ui": "workspace:*" + "@coss/ui": "workspace:*" "@evyweb/ioctopus": "npm:1.2.0" "@tanstack/react-table": "npm:8.20.6" "@tanstack/react-virtual": "npm:3.10.9"