diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx index a4fd91b02e..eb1b325a8a 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx @@ -11,6 +11,7 @@ import React, { useEffect, useState, useMemo } from "react"; import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import type { OrganizationBranding } from "@calcom/features/ee/organizations/context/provider"; +import { HAS_OPT_IN_FEATURES } from "@calcom/features/feature-opt-in/config"; import type { TeamFeatures } from "@calcom/features/flags/config"; import { useIsFeatureEnabledForTeam } from "@calcom/features/flags/hooks/useIsFeatureEnabledForTeam"; import { HOSTED_CAL_FEATURES, IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants"; @@ -73,6 +74,15 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { href: "/settings/my-account/push-notifications", trackingMetadata: { section: "my_account", page: "push_notifications" }, }, + ...(HAS_OPT_IN_FEATURES + ? [ + { + name: "features", + href: "/settings/my-account/features", + trackingMetadata: { section: "my_account", page: "features" }, + }, + ] + : []), // TODO // { name: "referrals", href: "/settings/my-account/referrals" }, ], @@ -190,6 +200,15 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { isExternalLink: true, trackingMetadata: { section: "organization", page: "admin_api" }, }, + ...(HAS_OPT_IN_FEATURES + ? [ + { + name: "features", + href: "/settings/organizations/features", + trackingMetadata: { section: "organization", page: "features" }, + }, + ] + : []), ], }, { @@ -637,6 +656,16 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record + {HAS_OPT_IN_FEATURES && ( + + )} {/* Hide if there is a parent ID */} {!team.parentId ? ( <> diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/features/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/features/page.tsx new file mode 100644 index 0000000000..b2cc47b605 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/features/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import type { ReactElement } from "react"; + +import { _generateMetadata } from "app/_utils"; + +import FeaturesView from "~/settings/my-account/features-view"; + +const generateMetadata = async (): Promise => + await _generateMetadata( + (t) => t("features"), + (t) => t("feature_opt_in_description"), + undefined, + undefined, + "/settings/my-account/features" + ); + +const Page = (): ReactElement => { + return ; +}; + +export { generateMetadata }; +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/features/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/features/page.tsx new file mode 100644 index 0000000000..254ea9e6cc --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/features/page.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { _generateMetadata } from "app/_utils"; + +import { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; +import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import OrganizationFeaturesView from "~/ee/organizations/features-view"; + +import { validateUserHasOrg } from "../../actions/validateUserHasOrg"; + +export const generateMetadata = async (): Promise => + await _generateMetadata( + (t) => t("features"), + (t) => t("feature_opt_in_org_description"), + undefined, + undefined, + "/settings/organizations/features" + ); + +const Page = async () => { + const session = await validateUserHasOrg(); + + const { canRead, canEdit } = await getResourcePermissions({ + userId: session.user.id, + teamId: session.user.profile.organizationId, + resource: Resource.FeatureOptIn, + userRole: session.user.org?.role, + fallbackRoles: { + read: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + update: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + }); + + if (!canRead) { + return redirect("/settings/organizations/profile"); + } + + return ; +}; + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx new file mode 100644 index 0000000000..173a91162c --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx @@ -0,0 +1,79 @@ +import type { Metadata } from "next"; +import { cookies, headers } from "next/headers"; +import { notFound, redirect } from "next/navigation"; + +import { _generateMetadata } from "app/_utils"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; +import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions"; +import { MembershipRole } from "@calcom/prisma/enums"; +import prisma from "@calcom/prisma"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +import TeamFeaturesView from "~/settings/teams/[id]/features-view"; + +type PageParams = { params: Promise<{ id: string }> }; + +export const generateMetadata = async ({ params }: PageParams): Promise => + await _generateMetadata( + (t) => t("features"), + (t) => t("feature_opt_in_team_description"), + undefined, + undefined, + `/settings/teams/${(await params).id}/features` + ); + +const Page = async ({ params }: PageParams) => { + const { id } = await params; + const teamId = Number(id); + + if (Number.isNaN(teamId)) { + return notFound(); + } + + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + + if (!session?.user?.id) { + return notFound(); + } + + const membership = await prisma.membership.findFirst({ + where: { + teamId, + userId: session.user.id, + accepted: true, + }, + select: { + role: true, + }, + }); + + if (!membership) { + return notFound(); + } + + const { canRead, canEdit } = await getResourcePermissions({ + userId: session.user.id, + teamId, + resource: Resource.FeatureOptIn, + userRole: membership.role, + fallbackRoles: { + read: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + update: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + }); + + if (!canRead) { + return redirect(`/settings/teams/${teamId}/profile`); + } + + return ; +}; + +export default Page; diff --git a/apps/web/modules/ee/organizations/features-view.tsx b/apps/web/modules/ee/organizations/features-view.tsx new file mode 100644 index 0000000000..0f7ae69f65 --- /dev/null +++ b/apps/web/modules/ee/organizations/features-view.tsx @@ -0,0 +1,29 @@ +"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 { useOrganizationFeatureOptIn } from "~/feature-opt-in/hooks"; + +interface OrganizationFeaturesViewProps { + canEdit: boolean; +} + +const OrganizationFeaturesView = ({ canEdit }: OrganizationFeaturesViewProps): ReactElement => { + const { t } = useLocale(); + const featureOptIn = useOrganizationFeatureOptIn(); + + return ( + + + + ); +}; + +export default OrganizationFeaturesView; diff --git a/apps/web/modules/feature-opt-in/hooks/index.ts b/apps/web/modules/feature-opt-in/hooks/index.ts new file mode 100644 index 0000000000..299c32db4c --- /dev/null +++ b/apps/web/modules/feature-opt-in/hooks/index.ts @@ -0,0 +1,4 @@ +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/useOrganizationFeatureOptIn.ts b/apps/web/modules/feature-opt-in/hooks/useOrganizationFeatureOptIn.ts new file mode 100644 index 0000000000..21d834cf8a --- /dev/null +++ b/apps/web/modules/feature-opt-in/hooks/useOrganizationFeatureOptIn.ts @@ -0,0 +1,90 @@ +"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"; + +function useMutationCallbacks(onSuccessCallback: () => void): { onSuccess: () => void; onError: () => void } { + const { t } = useLocale(); + return useMemo( + () => ({ + onSuccess: (): void => { + onSuccessCallback(); + showToast(t("settings_updated_successfully"), "success"); + }, + onError: (): void => { + showToast(t("error_updating_settings"), "error"); + }, + }), + [onSuccessCallback, t] + ); +} + +function normalizeFeatures( + data: Array<{ featureId: string; globalEnabled: boolean; teamState: FeatureState }> | undefined +): NormalizedFeature[] { + return (data ?? []).map((feature) => ({ + slug: feature.featureId, + globalEnabled: feature.globalEnabled, + currentState: feature.teamState, + })); +} + +function getOrgBlockedWarning(): string | null { + return null; +} + +function isOrgBlockedByHigherLevel(): boolean { + return false; +} + +/** + * Hook for managing feature opt-in at the organization level. + */ +export function useOrganizationFeatureOptIn(): UseFeatureOptInResult { + const { t } = useLocale(); + const utils = trpc.useUtils(); + + const featuresQuery = trpc.viewer.featureOptIn.listForOrganization.useQuery(undefined, { + refetchOnWindowFocus: false, + }); + const autoOptInQuery = trpc.viewer.featureOptIn.getOrganizationAutoOptIn.useQuery(undefined, { + refetchOnWindowFocus: false, + }); + + const invalidateFeatures = useCallback(() => utils.viewer.featureOptIn.listForOrganization.invalidate(), [utils]); + const invalidateFeaturesAndAutoOptIn = useCallback(() => { + utils.viewer.featureOptIn.getOrganizationAutoOptIn.invalidate(); + utils.viewer.featureOptIn.listForOrganization.invalidate(); + }, [utils]); + + const setStateMutationCallbacks = useMutationCallbacks(invalidateFeatures); + const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn); + + 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 setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ autoOptIn: checked }); + + return { + features, + autoOptIn: autoOptInQuery.data?.autoOptIn ?? false, + isLoading: featuresQuery.isLoading || autoOptInQuery.isLoading, + setFeatureState, + setAutoOptIn, + isStateMutationPending: setStateMutation.isPending, + isAutoOptInMutationPending: setAutoOptInMutation.isPending, + toggleLabels: { enabled: t("allow"), disabled: t("block"), inherit: t("let_users_decide") }, + autoOptInDescription: t("auto_opt_in_experimental_description_org"), + getBlockedWarning: getOrgBlockedWarning, + isBlockedByHigherLevel: isOrgBlockedByHigherLevel, + }; +} diff --git a/apps/web/modules/feature-opt-in/hooks/useTeamFeatureOptIn.ts b/apps/web/modules/feature-opt-in/hooks/useTeamFeatureOptIn.ts new file mode 100644 index 0000000000..afe980853b --- /dev/null +++ b/apps/web/modules/feature-opt-in/hooks/useTeamFeatureOptIn.ts @@ -0,0 +1,114 @@ +"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"; + +type FeatureBlockingState = { orgState?: FeatureState }; +type TeamFeatureData = { featureId: string; globalEnabled: boolean; teamState: FeatureState; orgState: FeatureState }; + +function useMutationCallbacks(onSuccessCallback: () => void): { onSuccess: () => void; onError: () => void } { + const { t } = useLocale(); + return useMemo( + () => ({ + onSuccess: (): void => { + onSuccessCallback(); + showToast(t("settings_updated_successfully"), "success"); + }, + onError: (): void => { + showToast(t("error_updating_settings"), "error"); + }, + }), + [onSuccessCallback, t] + ); +} + +function normalizeTeamFeatures(data: TeamFeatureData[] | undefined): NormalizedFeature[] { + return (data ?? []).map((feature) => ({ + slug: feature.featureId, + globalEnabled: feature.globalEnabled, + currentState: feature.teamState, + })); +} + +function buildBlockingStateMap(data: TeamFeatureData[] | undefined): Map { + const map = new Map(); + for (const feature of data ?? []) { + map.set(feature.featureId, { orgState: feature.orgState }); + } + return map; +} + +function createTeamBlockedWarningFn( + blockingStateMap: Map, + t: TFunction +): (feature: NormalizedFeature) => string | null { + return (feature: NormalizedFeature): string | null => { + const blockingState = blockingStateMap.get(feature.slug); + if (blockingState?.orgState === "disabled") { + return t("feature_blocked_by_org_warning"); + } + return null; + }; +} + +function createTeamIsBlockedByHigherLevelFn( + blockingStateMap: Map +): (feature: NormalizedFeature) => boolean { + return (feature: NormalizedFeature): boolean => { + const blockingState = blockingStateMap.get(feature.slug); + return blockingState?.orgState === "disabled"; + }; +} + +/** + * Hook for managing feature opt-in at the team level. + */ +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 invalidateFeatures = useCallback( + () => utils.viewer.featureOptIn.listForTeam.invalidate({ teamId }), + [utils, teamId] + ); + const invalidateFeaturesAndAutoOptIn = useCallback(() => { + utils.viewer.featureOptIn.getTeamAutoOptIn.invalidate({ teamId }); + utils.viewer.featureOptIn.listForTeam.invalidate({ teamId }); + }, [utils, teamId]); + + const setStateMutationCallbacks = useMutationCallbacks(invalidateFeatures); + const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn); + + const setStateMutation = trpc.viewer.featureOptIn.setTeamState.useMutation(setStateMutationCallbacks); + 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 getBlockedWarning = createTeamBlockedWarningFn(featureBlockingState, t); + const isBlockedByHigherLevel = createTeamIsBlockedByHigherLevelFn(featureBlockingState); + + return { + features, + autoOptIn: autoOptInQuery.data?.autoOptIn ?? false, + isLoading: featuresQuery.isLoading || autoOptInQuery.isLoading, + setFeatureState, + setAutoOptIn, + isStateMutationPending: setStateMutation.isPending, + isAutoOptInMutationPending: setAutoOptInMutation.isPending, + toggleLabels: { enabled: t("allow"), disabled: t("block"), inherit: t("let_users_decide") }, + autoOptInDescription: t("auto_opt_in_experimental_description_team"), + getBlockedWarning, + isBlockedByHigherLevel, + }; +} diff --git a/apps/web/modules/feature-opt-in/hooks/useUserFeatureOptIn.ts b/apps/web/modules/feature-opt-in/hooks/useUserFeatureOptIn.ts new file mode 100644 index 0000000000..70ccc5bde7 --- /dev/null +++ b/apps/web/modules/feature-opt-in/hooks/useUserFeatureOptIn.ts @@ -0,0 +1,112 @@ +"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"; + +type UserFeatureData = { + featureId: string; + globalEnabled: boolean; + userState: FeatureState | undefined; + effectiveReason: EffectiveStateReason; +}; + +function useMutationCallbacks(onSuccessCallback: () => void): { onSuccess: () => void; onError: () => void } { + const { t } = useLocale(); + return useMemo( + () => ({ + onSuccess: (): void => { + onSuccessCallback(); + showToast(t("settings_updated_successfully"), "success"); + }, + onError: (): void => { + showToast(t("error_updating_settings"), "error"); + }, + }), + [onSuccessCallback, t] + ); +} + +function normalizeUserFeatures(data: UserFeatureData[] | undefined): NormalizedFeature[] { + return (data ?? []).map((feature) => ({ + slug: feature.featureId, + globalEnabled: feature.globalEnabled, + currentState: feature.userState ?? "inherit", + effectiveReason: feature.effectiveReason, + })); +} + +function createUserBlockedWarningFn(t: TFunction): (feature: NormalizedFeature) => string | null { + return (feature: NormalizedFeature): string | null => { + if (!feature.effectiveReason) return null; + switch (feature.effectiveReason) { + case "feature_org_disabled": + return t("feature_blocked_by_org_warning"); + case "feature_all_teams_disabled": + 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"); + default: + return null; + } + }; +} + +function isUserBlockedByHigherLevel(feature: NormalizedFeature): boolean { + return ( + feature.effectiveReason === "feature_org_disabled" || + feature.effectiveReason === "feature_all_teams_disabled" || + feature.effectiveReason === "feature_any_team_disabled" + ); +} + +/** + * Hook for managing feature opt-in at the user (personal) level. + */ +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 invalidateFeatures = useCallback(() => utils.viewer.featureOptIn.listForUser.invalidate(), [utils]); + const invalidateFeaturesAndAutoOptIn = useCallback(() => { + utils.viewer.featureOptIn.getUserAutoOptIn.invalidate(); + utils.viewer.featureOptIn.listForUser.invalidate(); + }, [utils]); + + const setStateMutationCallbacks = useMutationCallbacks(invalidateFeatures); + const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn); + + const setStateMutation = trpc.viewer.featureOptIn.setUserState.useMutation(setStateMutationCallbacks); + 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 setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ autoOptIn: checked }); + const getBlockedWarning = createUserBlockedWarningFn(t); + + return { + features, + autoOptIn: autoOptInQuery.data?.autoOptIn ?? false, + isLoading: featuresQuery.isLoading || autoOptInQuery.isLoading, + setFeatureState, + setAutoOptIn, + isStateMutationPending: setStateMutation.isPending, + isAutoOptInMutationPending: setAutoOptInMutation.isPending, + toggleLabels: { enabled: t("feature_on"), disabled: t("feature_off"), inherit: t("use_default") }, + autoOptInDescription: t("auto_opt_in_experimental_description_personal"), + getBlockedWarning, + isBlockedByHigherLevel: isUserBlockedByHigherLevel, + }; +} diff --git a/apps/web/modules/settings/my-account/features-view.tsx b/apps/web/modules/settings/my-account/features-view.tsx new file mode 100644 index 0000000000..9c72e8722a --- /dev/null +++ b/apps/web/modules/settings/my-account/features-view.tsx @@ -0,0 +1,25 @@ +"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 { useUserFeatureOptIn } from "~/feature-opt-in/hooks"; + +const FeaturesView = (): ReactElement => { + const { t } = useLocale(); + const featureOptIn = useUserFeatureOptIn(); + + return ( + + + + ); +}; + +export default FeaturesView; diff --git a/apps/web/modules/settings/teams/[id]/features-view.tsx b/apps/web/modules/settings/teams/[id]/features-view.tsx new file mode 100644 index 0000000000..51e1ae0da4 --- /dev/null +++ b/apps/web/modules/settings/teams/[id]/features-view.tsx @@ -0,0 +1,30 @@ +"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 { useTeamFeatureOptIn } from "~/feature-opt-in/hooks"; + +interface TeamFeaturesViewProps { + teamId: number; + canEdit: boolean; +} + +const TeamFeaturesView = ({ teamId, canEdit }: TeamFeaturesViewProps): ReactElement => { + const { t } = useLocale(); + const featureOptIn = useTeamFeatureOptIn(teamId); + + return ( + + + + ); +}; + +export default TeamFeaturesView; diff --git a/apps/web/pages/api/trpc/featureOptIn/[trpc].ts b/apps/web/pages/api/trpc/featureOptIn/[trpc].ts new file mode 100644 index 0000000000..2d9cea9cb7 --- /dev/null +++ b/apps/web/pages/api/trpc/featureOptIn/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { featureOptInRouter } from "@calcom/trpc/server/routers/viewer/featureOptIn/_router"; + +export default createNextApiHandler(featureOptInRouter); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 5974b13983..a1c42ece68 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -130,6 +130,8 @@ "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.", "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.", @@ -4306,6 +4308,23 @@ "audit_logs_owner_not_in_organization": "The booking owner is not a member of your organization.", "audit_logs_permission_denied": "You do not have permission to view audit logs for this booking.", "audit_logs_permission_check_error": "An error occurred while checking permissions.", + "feature_opt_in": "Feature Opt-In", + "feature_opt_in_description": "Manage experimental features for your account", + "feature_opt_in_team_description": "Manage experimental features for your team", + "feature_opt_in_org_description": "Manage experimental features for your organization", + "let_users_decide": "Let users decide", + "use_default": "Use default", + "feature_on": "On", + "feature_off": "Off", + "auto_opt_in_experimental": "Automatically opt-in for future experimental features", + "auto_opt_in_experimental_description_personal": "Automatically opt into new experimental features, unless disabled by your team or organization", + "auto_opt_in_experimental_description_team": "Automatically opt team members into new experimental features, unless configured by the organization or opted out by members", + "auto_opt_in_experimental_description_org": "Automatically opt organization members into new experimental features, unless configured at team or user level", + "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.", + "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.", "unverified_email_oauth_error": "Please verify your email address before signing in with Google or SAML. Sign in with your password to resend the verification email.", "pbac_resource_feature_opt_in": "Feature Opt-In", diff --git a/packages/features/feature-opt-in/components/FeaturesSettings.tsx b/packages/features/feature-opt-in/components/FeaturesSettings.tsx new file mode 100644 index 0000000000..c73846a45a --- /dev/null +++ b/packages/features/feature-opt-in/components/FeaturesSettings.tsx @@ -0,0 +1,192 @@ +"use client"; + +import type { FeatureState } from "@calcom/features/flags/config"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import classNames from "@calcom/ui/classNames"; +import { Alert } from "@calcom/ui/components/alert"; +import { SettingsToggle, ToggleGroup } from "@calcom/ui/components/form"; +import { Icon } from "@calcom/ui/components/icon"; +import { SkeletonText } from "@calcom/ui/components/skeleton"; +import { Tooltip } from "@calcom/ui/components/tooltip"; +import type { ReactElement } from "react"; +import { useMemo } from "react"; + +import { getOptInFeatureConfig } from "../config"; +import type { NormalizedFeature, UseFeatureOptInResult } from "../types"; + +interface FeaturesSettingsProps { + /** The hook result - can be from useUserFeatureOptIn, useTeamFeatureOptIn, or useOrganizationFeatureOptIn */ + featureOptIn: UseFeatureOptInResult; + /** Whether the user can edit the feature settings. If false, all toggles will be disabled. */ + canEdit?: boolean; +} + +interface ToggleOption { + value: string; + label: string; +} + +function isEnabledViaAutoOptIn(feature: NormalizedFeature, autoOptIn: boolean): boolean { + return feature.currentState === "inherit" && autoOptIn && feature.globalEnabled; +} + +function handleValueChange( + val: string | undefined, + slug: string, + setFeatureState: (slug: string, state: FeatureState) => void +): void { + if (!val) return; + setFeatureState(slug, val as FeatureState); +} + +function LoadingSkeleton(): ReactElement { + return ( + + + + + + + ); +} + +function EmptyState({ message }: { message: string }): ReactElement { + return ; +} + +function FeatureItem({ + feature, + toggleOptions, + getBlockedWarning, + isBlockedByHigherLevel, + autoOptIn, + setFeatureState, + canEdit, + t, +}: { + feature: NormalizedFeature; + toggleOptions: ToggleOption[]; + getBlockedWarning: (feature: NormalizedFeature) => string | null; + isBlockedByHigherLevel: (feature: NormalizedFeature) => boolean; + autoOptIn: boolean; + setFeatureState: (slug: string, state: FeatureState) => void; + canEdit: boolean; + t: (key: string) => string; +}): ReactElement | null { + const config = getOptInFeatureConfig(feature.slug); + + const blockedWarning = getBlockedWarning(feature); + const enabledViaAutoOptInFlag = isEnabledViaAutoOptIn(feature, autoOptIn); + const blockedByHigherLevel = isBlockedByHigherLevel(feature); + const isDisabled = blockedByHigherLevel || !canEdit; + + const finalToggleOptions = useMemo(() => { + if (!isDisabled) { + return toggleOptions; + } + return toggleOptions.map((option) => ({ + ...option, + disabled: true, + })); + }, [toggleOptions, isDisabled]); + + if (!config) return null; + + return ( + + + + + {t(config.titleI18nKey)} + {blockedWarning && ( + + + + + + )} + {enabledViaAutoOptInFlag && ( + + + + + + )} + + {t(config.descriptionI18nKey)} + + + handleValueChange(val, feature.slug, setFeatureState) + } + options={finalToggleOptions} + /> + + + ); +} + +export function FeaturesSettings({ featureOptIn, canEdit = true }: FeaturesSettingsProps): ReactElement { + const { t } = useLocale(); + const { + features, + autoOptIn, + isLoading, + setFeatureState, + setAutoOptIn, + isAutoOptInMutationPending, + toggleLabels, + autoOptInDescription, + getBlockedWarning, + isBlockedByHigherLevel, + } = featureOptIn; + + if (isLoading) return ; + + const toggleOptions: ToggleOption[] = [ + { value: "disabled", label: toggleLabels.disabled }, + { value: "enabled", label: toggleLabels.enabled }, + { value: "inherit", label: toggleLabels.inherit }, + ]; + + return ( + <> + + {features.length === 0 && } + {features.length > 0 && ( + + {features.map((feature) => ( + + ))} + + )} + + + > + ); +} + +export default FeaturesSettings; diff --git a/packages/features/feature-opt-in/config.ts b/packages/features/feature-opt-in/config.ts index 61436bfe3e..27ec82dba1 100644 --- a/packages/features/feature-opt-in/config.ts +++ b/packages/features/feature-opt-in/config.ts @@ -1,9 +1,11 @@ import type { FeatureId } from "@calcom/features/flags/config"; +import type { OptInFeaturePolicy } from "./types"; export interface OptInFeatureConfig { slug: FeatureId; titleI18nKey: string; descriptionI18nKey: string; + policy: OptInFeaturePolicy; } /** @@ -16,6 +18,7 @@ export const OPT_IN_FEATURES: OptInFeatureConfig[] = [ // slug: "bookings-v3", // titleI18nKey: "bookings_v3_title", // descriptionI18nKey: "bookings_v3_description", + // policy: "permissive", // }, ]; @@ -27,9 +30,14 @@ export function getOptInFeatureConfig(slug: string): OptInFeatureConfig | undefi } /** - * Check if a feature slug is in the opt-in allowlist. + * Check if a slug is in the opt-in allowlist. * Acts as a type guard, narrowing the slug to FeatureId when true. */ export function isOptInFeature(slug: string): slug is FeatureId { return OPT_IN_FEATURES.some((f) => f.slug === slug); } + +/** + * Check if there are any opt-in features available. + */ +export const HAS_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.length > 0; diff --git a/packages/features/feature-opt-in/lib/computeEffectiveState.test.ts b/packages/features/feature-opt-in/lib/computeEffectiveState.test.ts index 94e1e02571..758abcb702 100644 --- a/packages/features/feature-opt-in/lib/computeEffectiveState.test.ts +++ b/packages/features/feature-opt-in/lib/computeEffectiveState.test.ts @@ -1,352 +1,467 @@ import { describe, it, expect } from "vitest"; - +import type { FeatureState } from "@calcom/features/flags/config"; import { computeEffectiveStateAcrossTeams } from "./computeEffectiveState"; +import type { OptInFeaturePolicy } from "../types"; + +/** + * Feature Opt-In Effective State Computation + * ========================================== + * + * This module computes whether a feature is effectively enabled for a user + * based on global, organization, team, and user-level settings. + * + * Two policies are supported: + * - `permissive` (default): User opt-in can activate the feature; disables only win if ALL teams disable + * - `strict`: User opt-in alone is NOT enough; requires explicit enable from org/team; ANY disable blocks + * + * + * SCENARIO TABLE - PERMISSIVE POLICY + * ============================================ + * + * | # | Global | Org | Teams | User | Result | Reason | + * |---|--------|----------|--------------------------|----------|---------|-------------------------------| + * | 1 | true | inherit | [inherit, inherit] | enabled | ALLOWED | User opt-in activates | + * | 2 | true | inherit | [enabled, inherit] | inherit | ALLOWED | Team enablement propagates | + * | 3 | true | inherit | [inherit, inherit] | inherit | BLOCKED | No explicit enablement | + * | 4 | true | inherit | [enabled, disabled] | inherit | ALLOWED | At least one team enabled | + * | 5 | true | enabled | [inherit] | inherit | ALLOWED | Org enablement propagates | + * | 6 | true | enabled | [disabled, disabled] | enabled | BLOCKED | All teams disabled | + * | 7 | true | disabled | [enabled] | enabled | BLOCKED | Org disabled blocks all | + * | 8 | true | inherit | [disabled] | enabled | BLOCKED | All teams disabled | + * | 9 | true | inherit | [enabled] | disabled | BLOCKED | User disabled | + * |10 | false | enabled | [enabled] | enabled | BLOCKED | Global disabled | + * + * + * SCENARIO TABLE - STRICT POLICY + * ============================== + * + * | # | Global | Org | Teams | User | Result | Reason | + * |---|--------|----------|--------------------------|----------|---------|-------------------------------| + * | 1 | true | inherit | [inherit, inherit] | inherit | BLOCKED | No explicit enablement | + * | 2 | true | inherit | [inherit, inherit] | enabled | BLOCKED | User-only not allowed | + * | 3 | true | inherit | [enabled, inherit] | inherit | ALLOWED | Team enablement works | + * | 4 | true | inherit | [enabled, inherit] | enabled | ALLOWED | Team + user enabled | + * | 5 | true | inherit | [enabled, disabled] | enabled | BLOCKED | Any team disabled blocks | + * | 6 | true | enabled | [inherit] | inherit | ALLOWED | Org enablement works | + * | 7 | true | enabled | [disabled] | enabled | BLOCKED | Any team disabled blocks | + * | 8 | true | disabled | [enabled] | enabled | BLOCKED | Org disabled blocks all | + * | 9 | true | inherit | [enabled] | disabled | BLOCKED | User disabled | + * |10 | false | enabled | [enabled] | enabled | BLOCKED | Global disabled | + * + * + * KEY DIFFERENCES BETWEEN POLICIES + * ================================ + * + * | Aspect | Permissive | Strict | + * |-----------------------------|-------------------------------|----------------------------------| + * | User opt-in alone | Can activate feature | NOT enough, needs org/team | + * | Team disable behavior | Only blocks if ALL disabled | ANY disable blocks | + * | Org/team enablement needed | No (user can self-enable) | Yes (required for activation) | + * + */ describe("computeEffectiveStateAcrossTeams", () => { - describe("when global is disabled", () => { - it("returns false regardless of other states", () => { + 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 }) => { expect( computeEffectiveStateAcrossTeams({ globalEnabled: false, - orgState: "enabled", - teamStates: ["enabled"], - userState: "enabled", + orgState, + teamStates, + userState, + policy, }) - ).toBe(false); + ).toEqual({ enabled: false, reason: "feature_global_disabled" }); }); }); - describe("when org is disabled", () => { - it("returns false regardless of team and user state", () => { + 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 }, + ])("blocks feature when org is disabled (policy: $policy)", ({ policy, teamStates, userState }) => { expect( computeEffectiveStateAcrossTeams({ globalEnabled: true, orgState: "disabled", - teamStates: ["enabled"], - userState: "enabled", + teamStates, + userState, + policy, }) - ).toBe(false); + ).toEqual({ enabled: false, reason: "feature_org_disabled" }); }); }); - describe("when org is enabled", () => { - describe("when all teams are disabled", () => { - it("returns false regardless of user state", () => { + describe("Permissive Policy", () => { + describe("User Opt-In Behavior", () => { + it("allows user to self-enable even when org and teams only inherit", () => { expect( computeEffectiveStateAcrossTeams({ globalEnabled: true, - orgState: "enabled", + orgState: "inherit", + teamStates: ["inherit", "inherit"], + userState: "enabled", + policy: "permissive", + }) + ).toEqual({ enabled: true, reason: "feature_enabled" }); + }); + + it("blocks when no explicit enablement exists anywhere", () => { + expect( + computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "inherit", + teamStates: ["inherit", "inherit"], + userState: "inherit", + policy: "permissive", + }) + ).toEqual({ enabled: false, reason: "feature_no_explicit_enablement" }); + }); + }); + + describe("Team Enablement Propagation", () => { + it("allows feature when at least one team is enabled", () => { + expect( + computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "inherit", + teamStates: ["enabled", "inherit", "inherit"], + userState: "inherit", + policy: "permissive", + }) + ).toEqual({ enabled: true, reason: "feature_enabled" }); + }); + + it("allows feature when one team enabled and another disabled (mixed)", () => { + expect( + computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "inherit", + teamStates: ["enabled", "disabled", "inherit"], + userState: "inherit", + policy: "permissive", + }) + ).toEqual({ enabled: true, reason: "feature_enabled" }); + }); + }); + + describe("Team Disable Behavior (ALL must disable)", () => { + it("blocks only when ALL teams have explicitly disabled", () => { + expect( + computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "inherit", teamStates: ["disabled", "disabled"], userState: "enabled", + policy: "permissive", }) - ).toBe(false); - }); - }); - - describe("when at least one team is enabled", () => { - it("returns true when user is enabled", () => { - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "enabled", - teamStates: ["enabled", "disabled"], - userState: "enabled", - }) - ).toBe(true); + ).toEqual({ enabled: false, reason: "feature_all_teams_disabled" }); }); - it("returns false when user is disabled", () => { - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "enabled", - teamStates: ["enabled", "disabled"], - userState: "disabled", - }) - ).toBe(false); - }); - - it("returns true when user inherits", () => { - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "enabled", - teamStates: ["enabled", "disabled"], - userState: "inherit", - }) - ).toBe(true); - }); - }); - - describe("when teams inherit from enabled org", () => { - it("returns true when user is enabled or inherits", () => { - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "enabled", - teamStates: ["inherit"], - userState: "enabled", - }) - ).toBe(true); - - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "enabled", - teamStates: ["inherit"], - userState: "inherit", - }) - ).toBe(true); - }); - }); - }); - - describe("when org inherits (or no org)", () => { - describe("when all teams are disabled", () => { - it("returns false regardless of user state", () => { + it("blocks when single team is disabled (user in one team)", () => { expect( computeEffectiveStateAcrossTeams({ globalEnabled: true, orgState: "inherit", teamStates: ["disabled"], userState: "enabled", + policy: "permissive", }) - ).toBe(false); + ).toEqual({ enabled: false, reason: "feature_all_teams_disabled" }); }); }); - describe("when at least one team is enabled", () => { - it("returns true when user is enabled or inherits", () => { + describe("Organization Enablement", () => { + it("allows feature when org is enabled and teams inherit", () => { expect( computeEffectiveStateAcrossTeams({ globalEnabled: true, - orgState: "inherit", - teamStates: ["enabled", "disabled"], - userState: "enabled", - }) - ).toBe(true); - - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "inherit", - teamStates: ["enabled"], + orgState: "enabled", + teamStates: ["inherit"], userState: "inherit", + policy: "permissive", }) - ).toBe(true); + ).toEqual({ enabled: true, reason: "feature_enabled" }); }); - it("returns false when user is disabled", () => { + it("blocks when org enabled but all teams disabled", () => { + expect( + computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "enabled", + teamStates: ["disabled", "disabled"], + userState: "enabled", + policy: "permissive", + }) + ).toEqual({ enabled: false, reason: "feature_all_teams_disabled" }); + }); + }); + + describe("User Disable Behavior", () => { + it("blocks when user explicitly disables (even with team enabled)", () => { expect( computeEffectiveStateAcrossTeams({ globalEnabled: true, orgState: "inherit", teamStates: ["enabled"], userState: "disabled", + policy: "permissive", }) - ).toBe(false); + ).toEqual({ enabled: false, reason: "feature_user_disabled" }); }); }); - describe("when teams only inherit (no org enabled)", () => { - it("returns true when user explicitly opts in", () => { + describe("No Teams Scenario", () => { + it("allows when org enabled and user inherits (no teams)", () => { expect( computeEffectiveStateAcrossTeams({ globalEnabled: true, - orgState: "inherit", - teamStates: ["inherit"], - userState: "enabled", + orgState: "enabled", + teamStates: [], + userState: "inherit", + policy: "permissive", }) - ).toBe(true); + ).toEqual({ enabled: true, reason: "feature_enabled" }); }); - it("returns false when user inherits because no explicit enablement above", () => { + it("allows when user self-enables (no teams, org inherits)", () => { expect( computeEffectiveStateAcrossTeams({ globalEnabled: true, orgState: "inherit", + teamStates: [], + userState: "enabled", + policy: "permissive", + }) + ).toEqual({ enabled: true, reason: "feature_enabled" }); + }); + + it("blocks when no enablement anywhere (no teams)", () => { + expect( + computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "inherit", + teamStates: [], + userState: "inherit", + policy: "permissive", + }) + ).toEqual({ enabled: false, reason: "feature_no_explicit_enablement" }); + }); + }); + }); + + describe("Strict Policy", () => { + describe("User Opt-In Alone NOT Sufficient", () => { + it("blocks when user enables but no org/team enablement exists", () => { + expect( + computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "inherit", + teamStates: ["inherit", "inherit"], + userState: "enabled", + policy: "strict", + }) + ).toEqual({ enabled: false, reason: "feature_user_only_not_allowed" }); + }); + + it("blocks when no explicit enablement exists anywhere", () => { + expect( + computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "inherit", + teamStates: ["inherit", "inherit"], + userState: "inherit", + policy: "strict", + }) + ).toEqual({ enabled: false, reason: "feature_no_explicit_enablement" }); + }); + }); + + describe("Team Enablement Required", () => { + it("allows when team is enabled and user inherits", () => { + expect( + computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "inherit", + teamStates: ["enabled", "inherit", "inherit"], + userState: "inherit", + policy: "strict", + }) + ).toEqual({ enabled: true, reason: "feature_enabled" }); + }); + + it("allows when team is enabled and user also enables", () => { + expect( + computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "inherit", + teamStates: ["enabled", "inherit", "inherit"], + userState: "enabled", + policy: "strict", + }) + ).toEqual({ enabled: true, reason: "feature_enabled" }); + }); + }); + + describe("ANY Team Disable Blocks", () => { + it("blocks when any team is disabled (even if another is enabled)", () => { + expect( + computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "inherit", + teamStates: ["enabled", "disabled", "inherit"], + userState: "enabled", + policy: "strict", + }) + ).toEqual({ enabled: false, reason: "feature_any_team_disabled" }); + }); + + it("blocks when single team is disabled", () => { + expect( + computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "inherit", + teamStates: ["disabled"], + userState: "enabled", + policy: "strict", + }) + ).toEqual({ enabled: false, reason: "feature_any_team_disabled" }); + }); + + it("blocks when org enabled but any team disabled", () => { + expect( + computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "enabled", + teamStates: ["disabled"], + userState: "enabled", + policy: "strict", + }) + ).toEqual({ enabled: false, reason: "feature_any_team_disabled" }); + }); + }); + + describe("Organization Enablement", () => { + it("allows when org is enabled and teams inherit", () => { + expect( + computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "enabled", teamStates: ["inherit"], userState: "inherit", + policy: "strict", }) - ).toBe(false); + ).toEqual({ enabled: true, reason: "feature_enabled" }); + }); + }); + + describe("User Disable Behavior", () => { + it("blocks when user explicitly disables (even with team enabled)", () => { + expect( + computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "inherit", + teamStates: ["enabled"], + userState: "disabled", + policy: "strict", + }) + ).toEqual({ enabled: false, reason: "feature_user_disabled" }); + }); + }); + + describe("No Teams Scenario", () => { + it("allows when org enabled (no teams)", () => { + expect( + computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "enabled", + teamStates: [], + userState: "inherit", + policy: "strict", + }) + ).toEqual({ enabled: true, reason: "feature_enabled" }); + }); + + it("blocks when user self-enables but no org enablement (no teams)", () => { + expect( + computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "inherit", + teamStates: [], + userState: "enabled", + policy: "strict", + }) + ).toEqual({ enabled: false, reason: "feature_user_only_not_allowed" }); }); }); }); - describe("when user has no teams", () => { - it("returns true when org is enabled and user is enabled/inherits", () => { - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "enabled", - teamStates: [], - userState: "enabled", - }) - ).toBe(true); - - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "enabled", - teamStates: [], - userState: "inherit", - }) - ).toBe(true); - }); - - it("returns false when org inherits and user has no explicit enablement", () => { + describe("Permissive Policy Additional Cases", () => { + it("allows user to self-enable with single inheriting team", () => { expect( computeEffectiveStateAcrossTeams({ globalEnabled: true, orgState: "inherit", - teamStates: [], - userState: "inherit", + teamStates: ["inherit"], + userState: "enabled", + policy: "permissive", }) - ).toBe(false); // No explicit enablement in chain, feature should be disabled + ).toEqual({ enabled: true, reason: "feature_enabled" }); }); - it("returns true when org inherits but user explicitly enables", () => { + it("allows feature with mixed team states (one enabled, one disabled)", () => { expect( computeEffectiveStateAcrossTeams({ globalEnabled: true, orgState: "inherit", - teamStates: [], - userState: "enabled", + teamStates: ["enabled", "disabled"], + userState: "inherit", + policy: "permissive", }) - ).toBe(true); // User explicit enablement is sufficient + ).toEqual({ enabled: true, reason: "feature_enabled" }); }); }); - describe("user opt-in behavior", () => { - it("allows user to opt-in regardless of org/team inheritance state", () => { - // User can opt-in even when org and all teams are just inheriting - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "inherit", - teamStates: ["inherit", "inherit"], - userState: "enabled", - }) - ).toBe(true); + describe("Policy Comparison (same inputs, different outcomes)", () => { + it("user-only enablement: allowed in permissive, blocked in strict", () => { + const input = { + globalEnabled: true, + orgState: "inherit" as FeatureState, + teamStates: ["inherit", "inherit"] as FeatureState[], + userState: "enabled" as FeatureState, + }; + + expect(computeEffectiveStateAcrossTeams({ ...input, policy: "permissive" })).toEqual({ + enabled: true, + reason: "feature_enabled", + }); + + expect(computeEffectiveStateAcrossTeams({ ...input, policy: "strict" })).toEqual({ + enabled: false, + reason: "feature_user_only_not_allowed", + }); }); - it("blocks user opt-in when all teams have explicitly disabled", () => { - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "inherit", - teamStates: ["disabled", "disabled"], - userState: "enabled", - }) - ).toBe(false); - }); + it("mixed team states: allowed in permissive, blocked in strict", () => { + const input = { + globalEnabled: true, + orgState: "inherit" as FeatureState, + teamStates: ["enabled", "disabled"] as FeatureState[], + userState: "enabled" as FeatureState, + }; - it("blocks user opt-in when org has explicitly disabled", () => { - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "disabled", - teamStates: ["inherit"], - userState: "enabled", - }) - ).toBe(false); - }); - }); + expect(computeEffectiveStateAcrossTeams({ ...input, policy: "permissive" })).toEqual({ + enabled: true, + reason: "feature_enabled", + }); - describe("truth table from design doc", () => { - it("org disabled, any teams, any user → false", () => { - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "disabled", - teamStates: ["enabled"], - userState: "enabled", - }) - ).toBe(false); - }); - - it("org enabled, all teams disabled, any user → false", () => { - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "enabled", - teamStates: ["disabled", "disabled"], - userState: "enabled", - }) - ).toBe(false); - }); - - it("org enabled, at least one team enabled/inherit, user disabled → false", () => { - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "enabled", - teamStates: ["enabled"], - userState: "disabled", - }) - ).toBe(false); - }); - - it("org enabled, at least one team enabled/inherit, user enabled/inherit → true", () => { - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "enabled", - teamStates: ["enabled"], - userState: "enabled", - }) - ).toBe(true); - - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "enabled", - teamStates: ["inherit"], - userState: "inherit", - }) - ).toBe(true); - }); - - it("org inherit/null, all teams disabled, any user → false", () => { - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "inherit", - teamStates: ["disabled"], - userState: "enabled", - }) - ).toBe(false); - }); - - it("org inherit/null, at least one team enabled, user disabled → false", () => { - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "inherit", - teamStates: ["enabled"], - userState: "disabled", - }) - ).toBe(false); - }); - - it("org inherit/null, at least one team enabled, user enabled/inherit → true", () => { - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "inherit", - teamStates: ["enabled"], - userState: "enabled", - }) - ).toBe(true); - - expect( - computeEffectiveStateAcrossTeams({ - globalEnabled: true, - orgState: "inherit", - teamStates: ["enabled"], - userState: "inherit", - }) - ).toBe(true); + expect(computeEffectiveStateAcrossTeams({ ...input, policy: "strict" })).toEqual({ + enabled: false, + reason: "feature_any_team_disabled", + }); }); }); }); diff --git a/packages/features/feature-opt-in/lib/computeEffectiveState.ts b/packages/features/feature-opt-in/lib/computeEffectiveState.ts index 8ac9c2af0c..c4645a6d57 100644 --- a/packages/features/feature-opt-in/lib/computeEffectiveState.ts +++ b/packages/features/feature-opt-in/lib/computeEffectiveState.ts @@ -1,19 +1,44 @@ import type { FeatureState } from "@calcom/features/flags/config"; +import type { OptInFeaturePolicy } from "../types"; + +export type EffectiveStateReason = + | "feature_global_disabled" + | "feature_org_disabled" + | "feature_all_teams_disabled" + | "feature_any_team_disabled" + | "feature_user_disabled" + | "feature_no_explicit_enablement" + | "feature_user_only_not_allowed" + | "feature_enabled"; + +export type EffectiveStateResult = { + enabled: boolean; + reason: EffectiveStateReason; +}; + /** * Computes the effective enabled state based on global, org, teams, and user settings. * - * The logic follows these rules: - * - Any level set to "disabled" blocks the feature (org blocks all, team blocks that team's users, user blocks self) - * - User can explicitly opt-in to enable the feature for themselves - * - "enabled" at org/team level provides enablement that users can inherit from - * - "inherit" passes through to the level above + * The logic depends on the policy: + * + * **Permissive policy (default):** + * - User opt-in can activate the feature + * - Any explicit enable above is sufficient + * - Disables only win if ALL teams disable + * + * **Strict policy:** + * - User opt-in alone is NOT enough - requires explicit enable from org/team + * - ANY explicit disable blocks + * + * Returns both the enabled state and the reason for that state. */ export function computeEffectiveStateAcrossTeams({ globalEnabled, orgState, teamStates, userState, + policy, }: { /** * Acts as a global kill switch. When false, the feature is disabled for everyone. @@ -24,11 +49,16 @@ export function computeEffectiveStateAcrossTeams({ orgState: FeatureState; teamStates: FeatureState[]; userState: FeatureState; -}): boolean { + /** + * Policy that determines how feature opt-in states are evaluated. + */ + policy: OptInFeaturePolicy; +}): EffectiveStateResult { // Derive all conditions upfront const orgEnabled = orgState === "enabled"; const orgDisabled = orgState === "disabled"; const anyTeamEnabled = teamStates.some((s) => s === "enabled"); + const anyTeamDisabled = teamStates.some((s) => s === "disabled"); const allTeamsDisabled = teamStates.length > 0 && teamStates.every((s) => s === "disabled"); const userEnabled = userState === "enabled"; const userDisabled = userState === "disabled"; @@ -36,13 +66,49 @@ export function computeEffectiveStateAcrossTeams({ // Explicit enablement exists above user level const hasExplicitEnablementAboveUser = orgEnabled || anyTeamEnabled; - // Define when feature is enabled (whitelist approach) - const isEnabled = - globalEnabled && - !userDisabled && - !orgDisabled && - !allTeamsDisabled && - (userEnabled || hasExplicitEnablementAboveUser); // User can opt-in directly, or inherit from org/team + // Check conditions in order of precedence and return with reason + if (!globalEnabled) { + return { enabled: false, reason: "feature_global_disabled" }; + } - return isEnabled; + if (orgDisabled) { + return { enabled: false, reason: "feature_org_disabled" }; + } + + if (policy === "strict") { + // Strict policy: ANY explicit disable blocks + if (anyTeamDisabled) { + return { enabled: false, reason: "feature_any_team_disabled" }; + } + + // Strict policy: User opt-in alone is NOT enough - requires explicit enable from org/team + if (userEnabled && !hasExplicitEnablementAboveUser) { + return { enabled: false, reason: "feature_user_only_not_allowed" }; + } + + if (userDisabled) { + return { enabled: false, reason: "feature_user_disabled" }; + } + + if (!hasExplicitEnablementAboveUser) { + return { enabled: false, reason: "feature_no_explicit_enablement" }; + } + + return { enabled: true, reason: "feature_enabled" }; + } + + // Permissive policy (default): disables only win if ALL teams disable + if (allTeamsDisabled) { + return { enabled: false, reason: "feature_all_teams_disabled" }; + } + + if (userDisabled) { + return { enabled: false, reason: "feature_user_disabled" }; + } + + if (!userEnabled && !hasExplicitEnablementAboveUser) { + return { enabled: false, reason: "feature_no_explicit_enablement" }; + } + + return { enabled: true, reason: "feature_enabled" }; } diff --git a/packages/features/feature-opt-in/services/FeatureOptInService.test.ts b/packages/features/feature-opt-in/services/FeatureOptInService.test.ts new file mode 100644 index 0000000000..73fddf2d71 --- /dev/null +++ b/packages/features/feature-opt-in/services/FeatureOptInService.test.ts @@ -0,0 +1,183 @@ +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 { FeatureOptInService } from "./FeatureOptInService"; + +// Mock the OPT_IN_FEATURES config +vi.mock("../config", () => ({ + OPT_IN_FEATURES: [ + { 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" }, + ], +})); + +describe("FeatureOptInService", () => { + let mockFeaturesRepository: { + getAllFeatures: ReturnType; + getTeamsFeatureStates: ReturnType; + }; + let service: FeatureOptInService; + + beforeEach(() => { + vi.resetAllMocks(); + + mockFeaturesRepository = { + getAllFeatures: vi.fn(), + getTeamsFeatureStates: vi.fn(), + }; + + service = new FeatureOptInService(mockFeaturesRepository as unknown as FeaturesRepository); + }); + + describe("listFeaturesForTeam", () => { + it("should return features with team state when no parent org", async () => { + mockFeaturesRepository.getAllFeatures.mockResolvedValue([ + { slug: "test-feature-1", enabled: true }, + { slug: "test-feature-2", enabled: true }, + ]); + + mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({ + "test-feature-1": { 1: "enabled" as FeatureState }, + "test-feature-2": { 1: "disabled" as FeatureState }, + }); + + const result = await service.listFeaturesForTeam({ teamId: 1 }); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + featureId: "test-feature-1", + globalEnabled: true, + teamState: "enabled", + orgState: "inherit", + }); + expect(result[1]).toEqual({ + featureId: "test-feature-2", + globalEnabled: true, + teamState: "disabled", + orgState: "inherit", + }); + + // Verify that only the team ID was queried (no parent org) + expect(mockFeaturesRepository.getTeamsFeatureStates).toHaveBeenCalledWith({ + teamIds: [1], + featureIds: ["test-feature-1", "test-feature-2"], + }); + }); + + it("should return features with team and org state when parent org exists", async () => { + mockFeaturesRepository.getAllFeatures.mockResolvedValue([ + { slug: "test-feature-1", enabled: true }, + { slug: "test-feature-2", enabled: true }, + ]); + + // Team ID is 1, Parent Org ID is 100 + mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({ + "test-feature-1": { 1: "enabled" as FeatureState, 100: "disabled" as FeatureState }, + "test-feature-2": { 1: "inherit" as FeatureState, 100: "enabled" as FeatureState }, + }); + + const result = await service.listFeaturesForTeam({ teamId: 1, parentOrgId: 100 }); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + featureId: "test-feature-1", + globalEnabled: true, + teamState: "enabled", + orgState: "disabled", + }); + expect(result[1]).toEqual({ + featureId: "test-feature-2", + globalEnabled: true, + teamState: "inherit", + orgState: "enabled", + }); + + // 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"], + }); + }); + + it("should return inherit for org state when parent org has no explicit state", async () => { + 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({ + "test-feature-1": { 1: "enabled" as FeatureState }, + }); + + const result = await service.listFeaturesForTeam({ teamId: 1, parentOrgId: 100 }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + featureId: "test-feature-1", + globalEnabled: true, + teamState: "enabled", + orgState: "inherit", + }); + }); + + it("should filter out globally disabled features", async () => { + mockFeaturesRepository.getAllFeatures.mockResolvedValue([ + { slug: "test-feature-1", enabled: true }, + { slug: "test-feature-2", enabled: false }, + ]); + + mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({ + "test-feature-1": { 1: "enabled" as FeatureState }, + "test-feature-2": { 1: "enabled" as FeatureState }, + }); + + const result = await service.listFeaturesForTeam({ teamId: 1 }); + + expect(result).toHaveLength(1); + expect(result[0].featureId).toBe("test-feature-1"); + }); + + it("should return inherit for team state when team has no explicit state", async () => { + mockFeaturesRepository.getAllFeatures.mockResolvedValue([ + { slug: "test-feature-1", enabled: true }, + ]); + + mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({ + "test-feature-1": {}, + }); + + const result = await service.listFeaturesForTeam({ teamId: 1 }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + featureId: "test-feature-1", + globalEnabled: true, + teamState: "inherit", + orgState: "inherit", + }); + }); + + it("should handle null parentOrgId the same as undefined", async () => { + mockFeaturesRepository.getAllFeatures.mockResolvedValue([ + { slug: "test-feature-1", enabled: true }, + ]); + + mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({ + "test-feature-1": { 1: "enabled" as FeatureState }, + }); + + const result = await service.listFeaturesForTeam({ teamId: 1, parentOrgId: null }); + + expect(result).toHaveLength(1); + expect(result[0].orgState).toBe("inherit"); + + // Verify that only the team ID was queried (no parent org) + expect(mockFeaturesRepository.getTeamsFeatureStates).toHaveBeenCalledWith({ + teamIds: [1], + featureIds: ["test-feature-1", "test-feature-2"], + }); + }); + }); +}); diff --git a/packages/features/feature-opt-in/services/FeatureOptInService.ts b/packages/features/feature-opt-in/services/FeatureOptInService.ts index 60df327419..6587c6700d 100644 --- a/packages/features/feature-opt-in/services/FeatureOptInService.ts +++ b/packages/features/feature-opt-in/services/FeatureOptInService.ts @@ -1,10 +1,63 @@ import type { FeatureId, FeatureState } from "@calcom/features/flags/config"; import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; -import { OPT_IN_FEATURES } from "../config"; +import type { OptInFeaturePolicy } from "../types"; +import { getOptInFeatureConfig, OPT_IN_FEATURES } from "../config"; import { applyAutoOptIn } from "../lib/applyAutoOptIn"; import { computeEffectiveStateAcrossTeams } from "../lib/computeEffectiveState"; -import type { IFeatureOptInService, ResolvedFeatureState } from "./IFeatureOptInService"; +import type { EffectiveStateReason, IFeatureOptInService, ResolvedFeatureState } from "./IFeatureOptInService"; + +type ListFeaturesForUserResult = { + featureId: FeatureId; + globalEnabled: boolean; + orgState: FeatureState; + teamStates: FeatureState[]; + userState: FeatureState | undefined; + effectiveEnabled: boolean; + effectiveReason: EffectiveStateReason; + orgAutoOptIn: boolean; + teamAutoOptIns: boolean[]; + userAutoOptIn: boolean; +}; + +type ListFeaturesForTeamResult = { + featureId: FeatureId; + globalEnabled: boolean; + teamState: FeatureState; + orgState: FeatureState; +}; + +function getOrgState(orgId: number | null, teamStatesById: Record): FeatureState { + if (orgId !== null) { + return teamStatesById[orgId] ?? "inherit"; + } + return "inherit"; +} + +function getOrgAutoOptIn(orgId: number | null, teamsAutoOptIn: Record): boolean { + if (orgId !== null) { + return teamsAutoOptIn[orgId] ?? false; + } + return false; +} + +function getTeamIdsToQuery(teamId: number, parentOrgId: number | null | undefined): number[] { + if (parentOrgId) { + return [teamId, parentOrgId]; + } + return [teamId]; +} + +function getOrgStateForTeam( + parentOrgId: number | null | undefined, + teamStates: Record>, + slug: FeatureId +): FeatureState { + if (parentOrgId) { + return teamStates[slug]?.[parentOrgId] ?? "inherit"; + } + return "inherit"; +} /** * Service class for managing feature opt-in logic. @@ -41,84 +94,99 @@ export class FeatureOptInService implements IFeatureOptInService { teamIds: number[]; featureIds: FeatureId[]; }): Promise> { - // Get org and team states in a single query - // Include orgId in the query if it exists const allTeamIds = orgId !== null ? [orgId, ...teamIds] : teamIds; const [allFeatures, allTeamStates, userStates, userAutoOptIn, teamsAutoOptIn] = await Promise.all([ this.featuresRepository.getAllFeatures(), - this.featuresRepository.getTeamsFeatureStates({ - teamIds: allTeamIds, - featureIds, - }), - this.featuresRepository.getUserFeatureStates({ - userId, - featureIds, - }), + this.featuresRepository.getTeamsFeatureStates({ teamIds: allTeamIds, featureIds }), + this.featuresRepository.getUserFeatureStates({ userId, featureIds }), this.featuresRepository.getUserAutoOptIn(userId), this.featuresRepository.getTeamsAutoOptIn(allTeamIds), ]); const globalEnabledMap = new Map(allFeatures.map((feature) => [feature.slug, feature.enabled ?? false])); - const resolvedStates: Record = {}; for (const featureId of featureIds) { - const globalEnabled = globalEnabledMap.get(featureId) ?? false; - const teamStatesById = allTeamStates[featureId] ?? {}; - - // Extract raw org state from the combined result - const orgState: FeatureState = orgId !== null ? teamStatesById[orgId] ?? "inherit" : "inherit"; - - // Extract raw team states from the combined result - const teamStates = teamIds.map((teamId) => teamStatesById[teamId] ?? "inherit"); - - const userState = userStates[featureId] ?? "inherit"; - - // Get auto-opt-in flags for this feature's hierarchy - const orgAutoOptIn = orgId !== null ? teamsAutoOptIn[orgId] ?? false : false; - const teamAutoOptIns = teamIds.map((teamId) => teamsAutoOptIn[teamId] ?? false); - - // Apply auto-opt-in transformation - const { effectiveOrgState, effectiveTeamStates, effectiveUserState } = applyAutoOptIn({ - orgState, - teamStates, - userState, - orgAutoOptIn, - teamAutoOptIns, - userAutoOptIn, - }); - - // Compute effective state with transformed states - const effectiveEnabled = computeEffectiveStateAcrossTeams({ - globalEnabled, - orgState: effectiveOrgState, - teamStates: effectiveTeamStates, - userState: effectiveUserState, - }); - - resolvedStates[featureId] = { + const state = this.resolveFeatureState( featureId, - globalEnabled, - orgState, // Raw state (before auto-opt-in transform) - teamStates, // Raw states - userState, // Raw state - effectiveEnabled, - // Auto-opt-in flags for UI - orgAutoOptIn, - teamAutoOptIns, - userAutoOptIn, - }; + orgId, + teamIds, + globalEnabledMap, + allTeamStates, + userStates, + teamsAutoOptIn, + userAutoOptIn + ); + resolvedStates[featureId] = state; } return resolvedStates; } + private resolveFeatureState( + featureId: FeatureId, + orgId: number | null, + teamIds: number[], + globalEnabledMap: Map, + allTeamStates: Record>, + userStates: Record, + teamsAutoOptIn: Record, + userAutoOptIn: boolean + ): ResolvedFeatureState { + const globalEnabled = globalEnabledMap.get(featureId) ?? false; + const teamStatesById = allTeamStates[featureId] ?? {}; + + const orgState = getOrgState(orgId, teamStatesById); + const teamStates = teamIds.map((teamId) => teamStatesById[teamId] ?? "inherit"); + const userState = userStates[featureId] ?? "inherit"; + const orgAutoOptIn = getOrgAutoOptIn(orgId, teamsAutoOptIn); + const teamAutoOptIns = teamIds.map((teamId) => teamsAutoOptIn[teamId] ?? false); + + const { effectiveOrgState, effectiveTeamStates, effectiveUserState } = applyAutoOptIn({ + orgState, + teamStates, + userState, + orgAutoOptIn, + teamAutoOptIns, + userAutoOptIn, + }); + + // Get the policy for this feature from the config + const featureConfig = getOptInFeatureConfig(featureId); + const policy: OptInFeaturePolicy = featureConfig?.policy ?? "permissive"; + + const { enabled: effectiveEnabled, reason: effectiveReason } = computeEffectiveStateAcrossTeams({ + globalEnabled, + orgState: effectiveOrgState, + teamStates: effectiveTeamStates, + userState: effectiveUserState, + policy, + }); + + return { + featureId, + globalEnabled, + orgState, + teamStates, + userState, + effectiveEnabled, + effectiveReason, + orgAutoOptIn, + teamAutoOptIns, + userAutoOptIn, + }; + } + /** * List all opt-in features with their states for a user across teams. * Only returns features that are in the allowlist and globally enabled. */ - async listFeaturesForUser(input: { userId: number; orgId: number | null; teamIds: number[] }) { + async listFeaturesForUser(input: { + userId: number; + orgId: number | null; + teamIds: number[]; + }): Promise { const { userId, orgId, teamIds } = input; const featureIds = OPT_IN_FEATURES.map((config) => config.slug); @@ -136,15 +204,19 @@ export class FeatureOptInService implements IFeatureOptInService { * List all opt-in features with their raw states for a team. * Used for team admin settings page to configure feature opt-in. * Only returns features that are in the allowlist and globally enabled. + * If parentOrgId is provided, also returns the organization state for each feature. */ - async listFeaturesForTeam(input: { teamId: number }) { - const { teamId } = input; + async listFeaturesForTeam(input: { + teamId: number; + parentOrgId?: number | null; + }): Promise { + const { teamId, parentOrgId } = input; + const teamIdsToQuery = getTeamIdsToQuery(teamId, parentOrgId); const [allFeatures, teamStates] = await Promise.all([ this.featuresRepository.getAllFeatures(), - // Get all team feature states in a single query this.featuresRepository.getTeamsFeatureStates({ - teamIds: [teamId], + teamIds: teamIdsToQuery, featureIds: OPT_IN_FEATURES.map((config) => config.slug), }), ]); @@ -153,12 +225,9 @@ export class FeatureOptInService implements IFeatureOptInService { const globalFeature = allFeatures.find((f) => f.slug === config.slug); const globalEnabled = globalFeature?.enabled ?? false; const teamState = teamStates[config.slug]?.[teamId] ?? "inherit"; + const orgState = getOrgStateForTeam(parentOrgId, teamStates, config.slug); - return { - featureId: config.slug, - globalEnabled, - teamState, - }; + return { featureId: config.slug, globalEnabled, teamState, orgState }; }); return results.filter((result) => result.globalEnabled); @@ -172,14 +241,10 @@ export class FeatureOptInService implements IFeatureOptInService { input: | { userId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: number } | { userId: number; featureId: FeatureId; state: "inherit" } - ) { + ): Promise { const { userId, featureId, state } = input; if (state === "inherit") { - await this.featuresRepository.setUserFeatureState({ - userId, - featureId, - state, - }); + await this.featuresRepository.setUserFeatureState({ userId, featureId, state }); } else { const { assignedBy } = input; await this.featuresRepository.setUserFeatureState({ @@ -199,14 +264,10 @@ export class FeatureOptInService implements IFeatureOptInService { input: | { teamId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: number } | { teamId: number; featureId: FeatureId; state: "inherit" } - ) { + ): Promise { const { teamId, featureId, state } = input; if (state === "inherit") { - await this.featuresRepository.setTeamFeatureState({ - teamId, - featureId, - state, - }); + await this.featuresRepository.setTeamFeatureState({ teamId, featureId, state }); } else { const { assignedBy } = input; await this.featuresRepository.setTeamFeatureState({ diff --git a/packages/features/feature-opt-in/services/IFeatureOptInService.ts b/packages/features/feature-opt-in/services/IFeatureOptInService.ts index 99b45c77e2..57115b1e50 100644 --- a/packages/features/feature-opt-in/services/IFeatureOptInService.ts +++ b/packages/features/feature-opt-in/services/IFeatureOptInService.ts @@ -1,13 +1,17 @@ import type { FeatureId, FeatureState } from "@calcom/features/flags/config"; +import type { EffectiveStateReason } from "../lib/computeEffectiveState"; + +export type { EffectiveStateReason }; + export type ResolvedFeatureState = { featureId: FeatureId; globalEnabled: boolean; - orgState: FeatureState; // Raw state (before auto-opt-in transform) - teamStates: FeatureState[]; // Raw states - userState: FeatureState | undefined; // Raw state + orgState: FeatureState; + teamStates: FeatureState[]; + userState: FeatureState | undefined; effectiveEnabled: boolean; - // Auto-opt-in flags for UI to show checkbox state + effectiveReason: EffectiveStateReason; orgAutoOptIn: boolean; teamAutoOptIns: boolean[]; userAutoOptIn: boolean; @@ -24,8 +28,8 @@ export interface IFeatureOptInService { ResolvedFeatureState[] >; listFeaturesForTeam( - input: { teamId: number } - ): Promise<{ featureId: FeatureId; globalEnabled: boolean; teamState: FeatureState }[]>; + input: { teamId: number; parentOrgId?: number | null } + ): Promise<{ featureId: FeatureId; globalEnabled: boolean; teamState: FeatureState; orgState: FeatureState }[]>; setUserFeatureState( input: | { userId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: number } diff --git a/packages/features/feature-opt-in/types.ts b/packages/features/feature-opt-in/types.ts new file mode 100644 index 0000000000..702b61623e --- /dev/null +++ b/packages/features/feature-opt-in/types.ts @@ -0,0 +1,70 @@ +import type { EffectiveStateReason } from "@calcom/features/feature-opt-in/lib/computeEffectiveState"; +import type { FeatureState } from "@calcom/features/flags/config"; + +/** + * Policy that determines how feature opt-in states are evaluated. + * + * - `permissive`: User opt-in can activate the feature; any explicit enable above is sufficient; + * disables only win if ALL teams disable. + * - `strict`: User opt-in alone is not enough; requires explicit enable from org/team; + * ANY explicit disable blocks. + */ +export type OptInFeaturePolicy = "permissive" | "strict"; + +/** + * Normalized feature representation used across all scopes (user, team, org). + */ +export interface NormalizedFeature { + slug: string; + globalEnabled: boolean; + /** The current state value for this scope (userState, teamState, etc.) */ + currentState: FeatureState; + /** The reason for the effective state (only available for user scope) */ + effectiveReason?: EffectiveStateReason; +} + +/** + * Toggle option labels - differ between user and team/org scopes. + */ +export interface ToggleLabels { + enabled: string; + disabled: string; + inherit: string; +} + +/** + * Common interface returned by all feature opt-in hooks. + * This allows a single component to work with any scope. + */ +export interface UseFeatureOptInResult { + // Query state + features: NormalizedFeature[]; + autoOptIn: boolean; + isLoading: boolean; + + // Mutations + setFeatureState: (slug: string, state: FeatureState) => void; + setAutoOptIn: (checked: boolean) => void; + isStateMutationPending: boolean; + isAutoOptInMutationPending: boolean; + + // Scope-specific configuration + toggleLabels: ToggleLabels; + + /** Description for the auto opt-in toggle, varies by scope */ + autoOptInDescription: string; + + /** + * For user scope: returns a warning message if the feature is blocked by org/team. + * Returns null if not blocked or not applicable (team/org scopes). + */ + getBlockedWarning: (feature: NormalizedFeature) => string | null; + + /** + * Returns true if the feature toggle should be disabled because it's blocked by a higher level. + * - User scope: blocked by org or all teams + * - Team scope: blocked by org + * - Org scope: never blocked (top level) + */ + isBlockedByHigherLevel: (feature: NormalizedFeature) => boolean; +} diff --git a/packages/trpc/react/shared.ts b/packages/trpc/react/shared.ts index feeeea7359..2e93461bf6 100644 --- a/packages/trpc/react/shared.ts +++ b/packages/trpc/react/shared.ts @@ -19,6 +19,7 @@ export const ENDPOINTS = [ "eventTypesHeavy", "features", "holidays", + "featureOptIn", "i18n", "insights", "me", diff --git a/packages/trpc/server/procedures/pbacProcedures.test.ts b/packages/trpc/server/procedures/pbacProcedures.test.ts new file mode 100644 index 0000000000..cd542048fe --- /dev/null +++ b/packages/trpc/server/procedures/pbacProcedures.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry"; +import type { MembershipRole } from "@calcom/prisma/enums"; + +import { TRPCError } from "@trpc/server"; + +import authedProcedure from "./authedProcedure"; +// Import after mocks are set up +import { createTeamPbacProcedure, createOrgPbacProcedure } from "./pbacProcedures"; + +// Mock dependencies - use factory functions to avoid hoisting issues +const mockCheckPermission = vi.fn(); + +vi.mock("@calcom/features/pbac/services/permission-check.service", () => { + return { + PermissionCheckService: class { + checkPermission = mockCheckPermission; + }, + }; +}); + +vi.mock("./authedProcedure", () => ({ + default: { + input: vi.fn().mockReturnThis(), + use: vi.fn(), + }, +})); + +// Cast the mocked authedProcedure to access mock methods +const mockAuthedProcedure = authedProcedure as unknown as { + input: ReturnType & { mockReturnThis: () => void }; + use: ReturnType; +}; + +describe("Feature Opt-In PBAC Procedures", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCheckPermission.mockReset(); + mockAuthedProcedure.use.mockClear(); + mockAuthedProcedure.input.mockClear(); + }); + + describe("createTeamPbacProcedure", () => { + const testPermission: PermissionString = "team.read"; + + it("should create a procedure with input schema for teamId", () => { + createTeamPbacProcedure(testPermission); + + expect(mockAuthedProcedure.input).toHaveBeenCalled(); + expect(mockAuthedProcedure.use).toHaveBeenCalled(); + }); + + describe("middleware behavior", () => { + const mockCtx = { + user: { + id: 1, + }, + }; + + it("should allow access when user has PBAC permission", async () => { + createTeamPbacProcedure(testPermission); + const middleware = mockAuthedProcedure.use.mock.calls[0][0]; + + mockCheckPermission.mockResolvedValue(true); + + const next = vi.fn().mockResolvedValue("success"); + const result = await middleware({ + ctx: mockCtx, + input: { teamId: 10 }, + next, + }); + + expect(result).toBe("success"); + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 1, + teamId: 10, + permission: testPermission, + fallbackRoles: ["ADMIN", "OWNER"], + }); + }); + + it("should throw FORBIDDEN when user lacks PBAC permission", async () => { + createTeamPbacProcedure(testPermission); + const middleware = mockAuthedProcedure.use.mock.calls[0][0]; + + mockCheckPermission.mockResolvedValue(false); + + const next = vi.fn(); + + await expect( + middleware({ + ctx: mockCtx, + input: { teamId: 10 }, + next, + }) + ).rejects.toThrow( + new TRPCError({ + code: "FORBIDDEN", + message: `Permission required: ${testPermission}`, + }) + ); + }); + + it("should use custom fallback roles when provided", async () => { + const customFallback: MembershipRole[] = ["OWNER"]; + createTeamPbacProcedure(testPermission, customFallback); + const middleware = mockAuthedProcedure.use.mock.calls[0][0]; + + mockCheckPermission.mockResolvedValue(true); + + const next = vi.fn().mockResolvedValue("success"); + await middleware({ + ctx: mockCtx, + input: { teamId: 10 }, + next, + }); + + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 1, + teamId: 10, + permission: testPermission, + fallbackRoles: customFallback, + }); + }); + }); + + describe("different permission scenarios", () => { + const permissions: { permission: PermissionString; operation: string }[] = [ + { permission: "team.read", operation: "read" }, + { permission: "team.update", operation: "update" }, + ]; + + permissions.forEach(({ permission, operation }) => { + it(`should use ${permission} for ${operation} operations`, async () => { + createTeamPbacProcedure(permission); + const middleware = mockAuthedProcedure.use.mock.calls[mockAuthedProcedure.use.mock.calls.length - 1][0]; + + mockCheckPermission.mockResolvedValue(true); + + const next = vi.fn().mockResolvedValue("success"); + await middleware({ + ctx: { user: { id: 1 } }, + input: { teamId: 10 }, + next, + }); + + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 1, + teamId: 10, + permission, + fallbackRoles: ["ADMIN", "OWNER"], + }); + }); + }); + }); + }); + + describe("createOrgPbacProcedure", () => { + const testPermission: PermissionString = "organization.read"; + + it("should create a procedure with middleware", () => { + createOrgPbacProcedure(testPermission); + + expect(mockAuthedProcedure.use).toHaveBeenCalled(); + }); + + describe("middleware behavior", () => { + it("should throw BAD_REQUEST when user has no organizationId", async () => { + createOrgPbacProcedure(testPermission); + const middleware = mockAuthedProcedure.use.mock.calls[0][0]; + + const mockCtx = { + user: { + id: 1, + organizationId: null, + }, + }; + + const next = vi.fn(); + + await expect( + middleware({ + ctx: mockCtx, + next, + }) + ).rejects.toThrow( + new TRPCError({ + code: "BAD_REQUEST", + message: "You are not a member of any organization.", + }) + ); + }); + + it("should throw FORBIDDEN when user lacks PBAC permission", async () => { + createOrgPbacProcedure(testPermission); + const middleware = mockAuthedProcedure.use.mock.calls[0][0]; + + const mockCtx = { + user: { + id: 1, + organizationId: 100, + }, + }; + + mockCheckPermission.mockResolvedValue(false); + + const next = vi.fn(); + + await expect( + middleware({ + ctx: mockCtx, + next, + }) + ).rejects.toThrow( + new TRPCError({ + code: "FORBIDDEN", + message: `Permission required: ${testPermission}`, + }) + ); + + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 1, + teamId: 100, + permission: testPermission, + fallbackRoles: ["ADMIN", "OWNER"], + }); + }); + + it("should allow access and add organizationId to context when user has permission", async () => { + createOrgPbacProcedure(testPermission); + const middleware = mockAuthedProcedure.use.mock.calls[0][0]; + + const mockCtx = { + user: { + id: 1, + organizationId: 100, + }, + }; + + mockCheckPermission.mockResolvedValue(true); + + const next = vi.fn().mockImplementation(({ ctx }) => { + // Verify that organizationId is added to context + expect(ctx.organizationId).toBe(100); + return "success"; + }); + + const result = await middleware({ + ctx: mockCtx, + next, + }); + + expect(result).toBe("success"); + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 1, + teamId: 100, + permission: testPermission, + fallbackRoles: ["ADMIN", "OWNER"], + }); + }); + + it("should use custom fallback roles when provided", async () => { + const customFallback: MembershipRole[] = ["OWNER"]; + createOrgPbacProcedure(testPermission, customFallback); + const middleware = mockAuthedProcedure.use.mock.calls[0][0]; + + const mockCtx = { + user: { + id: 1, + organizationId: 100, + }, + }; + + mockCheckPermission.mockResolvedValue(true); + + const next = vi.fn().mockResolvedValue("success"); + await middleware({ + ctx: mockCtx, + next, + }); + + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 1, + teamId: 100, + permission: testPermission, + fallbackRoles: customFallback, + }); + }); + }); + + describe("different permission scenarios", () => { + const permissions: { permission: PermissionString; operation: string }[] = [ + { permission: "organization.read", operation: "read" }, + { permission: "organization.update", operation: "update" }, + ]; + + permissions.forEach(({ permission, operation }) => { + it(`should use ${permission} for ${operation} operations`, async () => { + createOrgPbacProcedure(permission); + const middleware = mockAuthedProcedure.use.mock.calls[mockAuthedProcedure.use.mock.calls.length - 1][0]; + + const mockCtx = { + user: { + id: 1, + organizationId: 100, + }, + }; + + mockCheckPermission.mockResolvedValue(true); + + const next = vi.fn().mockResolvedValue("success"); + await middleware({ + ctx: mockCtx, + next, + }); + + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 1, + teamId: 100, + permission, + fallbackRoles: ["ADMIN", "OWNER"], + }); + }); + }); + }); + }); +}); diff --git a/packages/trpc/server/procedures/pbacProcedures.ts b/packages/trpc/server/procedures/pbacProcedures.ts new file mode 100644 index 0000000000..d84838120f --- /dev/null +++ b/packages/trpc/server/procedures/pbacProcedures.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; + +import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { TRPCError } from "@trpc/server"; + +import authedProcedure from "./authedProcedure"; + +/** + * Creates a procedure that checks team-level PBAC permissions. + * The teamId is expected to come from input.teamId. + * + * @param permission - The specific permission required (e.g., "team.read", "team.update") + * @param fallbackRoles - Roles to check when PBAC is disabled (defaults to ["ADMIN", "OWNER"]) + * @returns A procedure that checks the specified permission for the team + */ +function createTeamPbacProcedure( + permission: PermissionString, + fallbackRoles: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER] +): ReturnType { + return authedProcedure + .input( + z.object({ + teamId: z.number(), + }) + ) + .use(async ({ ctx, input, next }) => { + const permissionCheckService: PermissionCheckService = new PermissionCheckService(); + const hasPermission: boolean = await permissionCheckService.checkPermission({ + userId: ctx.user.id, + teamId: input.teamId, + permission, + fallbackRoles, + }); + + if (!hasPermission) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `Permission required: ${permission}`, + }); + } + + return next(); + }); +} + +/** + * Creates a procedure that checks organization-level PBAC permissions. + * The organizationId is taken from ctx.user.organizationId. + * + * @param permission - The specific permission required (e.g., "organization.read", "organization.update") + * @param fallbackRoles - Roles to check when PBAC is disabled (defaults to ["ADMIN", "OWNER"]) + * @returns A procedure that checks the specified permission for the organization and adds organizationId to context + */ +function createOrgPbacProcedure( + permission: PermissionString, + fallbackRoles: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER] +) { + return authedProcedure.use(async ({ ctx, next }) => { + const organizationId = ctx.user.organizationId; + + if (!organizationId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You are not a member of any organization.", + }); + } + + const permissionCheckService = new PermissionCheckService(); + const hasPermission = await permissionCheckService.checkPermission({ + userId: ctx.user.id, + teamId: organizationId, + permission, + fallbackRoles, + }); + + if (!hasPermission) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `Permission required: ${permission}`, + }); + } + + return next({ + ctx: { + ...ctx, + organizationId, + }, + }); + }); +} + +export { createTeamPbacProcedure, createOrgPbacProcedure }; diff --git a/packages/trpc/server/routers/viewer/featureOptIn/_router.ts b/packages/trpc/server/routers/viewer/featureOptIn/_router.ts index e821d36081..a7ee117516 100644 --- a/packages/trpc/server/routers/viewer/featureOptIn/_router.ts +++ b/packages/trpc/server/routers/viewer/featureOptIn/_router.ts @@ -1,19 +1,24 @@ +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 { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; -import { MembershipRole } from "@calcom/prisma/enums"; +import { prisma } from "@calcom/prisma"; import { TRPCError } from "@trpc/server"; import authedProcedure from "../../../procedures/authedProcedure"; import { router } from "../../../trpc"; +import { createOrgPbacProcedure, createTeamPbacProcedure } from "../../../procedures/pbacProcedures"; -const featureStateSchema = 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); /** * Helper to get user's org and team IDs from their memberships. @@ -57,64 +62,23 @@ export const featureOptInRouter = router({ /** * Get all opt-in features with states for a team settings page. * Used by team admins to configure feature opt-in for their team. + * Also returns the organization state if the team belongs to an organization. */ - listForTeam: authedProcedure - .input( - z.object({ - teamId: z.number(), - }) - ) - .query(async ({ ctx, input }) => { - const permissionCheckService = new PermissionCheckService(); - const hasPermission = await permissionCheckService.checkPermission({ - userId: ctx.user.id, - teamId: input.teamId, - permission: "team.read", - fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], - }); + listForTeam: createTeamPbacProcedure("featureOptIn.read").query(async ({ input }) => { + // Get the team's parent organization ID (if any) + const parentOrg = await teamRepository.findParentOrganizationByTeamId(input.teamId); + const parentOrgId = parentOrg?.id ?? null; - if (!hasPermission) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You do not have permission to view team feature settings.", - }); - } - - return featureOptInService.listFeaturesForTeam({ teamId: input.teamId }); - }), + return featureOptInService.listFeaturesForTeam({ teamId: input.teamId, parentOrgId }); + }), /** * Get all opt-in features with states for organization settings page. * Used by org admins to configure feature opt-in for their organization. - * Uses the organization from the current user's context. */ - listForOrganization: authedProcedure.query(async ({ ctx }) => { - const organizationId = ctx.user.organizationId; - - if (!organizationId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "You are not a member of any organization.", - }); - } - - const permissionCheckService = new PermissionCheckService(); - const hasPermission = await permissionCheckService.checkPermission({ - userId: ctx.user.id, - teamId: organizationId, - permission: "organization.read", - fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], - }); - - if (!hasPermission) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You do not have permission to view organization feature settings.", - }); - } - + listForOrganization: createOrgPbacProcedure("featureOptIn.read").query(async ({ ctx }) => { // Organizations use the same listFeaturesForTeam since they're stored in TeamFeatures - return featureOptInService.listFeaturesForTeam({ teamId: organizationId }); + return featureOptInService.listFeaturesForTeam({ teamId: ctx.organizationId }); }), /** @@ -123,21 +87,21 @@ export const featureOptInRouter = router({ setUserState: authedProcedure .input( z.object({ - featureId: z.string(), + slug: z.string(), state: featureStateSchema, }) ) .mutation(async ({ ctx, input }) => { - if (!isOptInFeature(input.featureId)) { + if (!isOptInFeature(input.slug)) { throw new TRPCError({ code: "BAD_REQUEST", - message: "Invalid featureId. This feature is not opt-in configurable.", + message: "Invalid slug. This feature is not opt-in configurable.", }); } await featureOptInService.setUserFeatureState({ userId: ctx.user.id, - featureId: input.featureId, + featureId: input.slug, state: input.state, assignedBy: ctx.user.id, }); @@ -148,40 +112,24 @@ export const featureOptInRouter = router({ /** * Set team's feature state (requires team admin). */ - setTeamState: authedProcedure + setTeamState: createTeamPbacProcedure("featureOptIn.update") .input( z.object({ - teamId: z.number(), - featureId: z.string(), + slug: z.string(), state: featureStateSchema, }) ) .mutation(async ({ ctx, input }) => { - if (!isOptInFeature(input.featureId)) { + if (!isOptInFeature(input.slug)) { throw new TRPCError({ code: "BAD_REQUEST", - message: "Invalid featureId. This feature is not opt-in configurable.", - }); - } - - const permissionCheckService = new PermissionCheckService(); - const hasPermission = await permissionCheckService.checkPermission({ - userId: ctx.user.id, - teamId: input.teamId, - permission: "team.update", - fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], - }); - - if (!hasPermission) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You do not have permission to update team feature settings.", + message: "Invalid slug. This feature is not opt-in configurable.", }); } await featureOptInService.setTeamFeatureState({ teamId: input.teamId, - featureId: input.featureId, + featureId: input.slug, state: input.state, assignedBy: ctx.user.id, }); @@ -191,55 +139,96 @@ export const featureOptInRouter = router({ /** * Set organization's feature state (requires org admin). - * Uses the organization from the current user's context. */ - setOrganizationState: authedProcedure + setOrganizationState: createOrgPbacProcedure("featureOptIn.update") .input( z.object({ - featureId: z.string(), + slug: z.string(), state: featureStateSchema, }) ) .mutation(async ({ ctx, input }) => { - if (!isOptInFeature(input.featureId)) { + if (!isOptInFeature(input.slug)) { throw new TRPCError({ code: "BAD_REQUEST", - message: "Invalid featureId. This feature is not opt-in configurable.", - }); - } - - const organizationId = ctx.user.organizationId; - - if (!organizationId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "You are not a member of any organization.", - }); - } - - const permissionCheckService = new PermissionCheckService(); - const hasPermission = await permissionCheckService.checkPermission({ - userId: ctx.user.id, - teamId: organizationId, - permission: "organization.update", - fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], - }); - - if (!hasPermission) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You do not have permission to update organization feature settings.", + message: "Invalid slug. This feature is not opt-in configurable.", }); } // Organizations use the same TeamFeatures table await featureOptInService.setTeamFeatureState({ - teamId: organizationId, - featureId: input.featureId, + teamId: ctx.organizationId, + featureId: input.slug, state: input.state, assignedBy: ctx.user.id, }); return { success: true }; }), + + /** + * Get user's auto opt-in preference. + */ + getUserAutoOptIn: authedProcedure.query(async ({ ctx }) => { + const autoOptIn = await featuresRepository.getUserAutoOptIn(ctx.user.id); + return { autoOptIn }; + }), + + /** + * Set user's auto opt-in preference. + */ + setUserAutoOptIn: authedProcedure + .input( + z.object({ + autoOptIn: z.boolean(), + }) + ) + .mutation(async ({ ctx, input }) => { + await featuresRepository.setUserAutoOptIn(ctx.user.id, input.autoOptIn); + return { success: true }; + }), + + /** + * Get team's auto opt-in preference (requires team admin). + */ + getTeamAutoOptIn: createTeamPbacProcedure("featureOptIn.read").query(async ({ input }) => { + const result = await featuresRepository.getTeamsAutoOptIn([input.teamId]); + return { autoOptIn: result[input.teamId] ?? false }; + }), + + /** + * Set team's auto opt-in preference (requires team admin). + */ + setTeamAutoOptIn: createTeamPbacProcedure("featureOptIn.update") + .input( + z.object({ + autoOptIn: z.boolean(), + }) + ) + .mutation(async ({ input }) => { + await featuresRepository.setTeamAutoOptIn(input.teamId, input.autoOptIn); + return { success: true }; + }), + + /** + * Get organization's auto opt-in preference (requires org admin). + */ + getOrganizationAutoOptIn: createOrgPbacProcedure("featureOptIn.read").query(async ({ ctx }) => { + const result = await featuresRepository.getTeamsAutoOptIn([ctx.organizationId]); + return { autoOptIn: result[ctx.organizationId] ?? false }; + }), + + /** + * Set organization's auto opt-in preference (requires org admin). + */ + setOrganizationAutoOptIn: createOrgPbacProcedure("featureOptIn.update") + .input( + z.object({ + autoOptIn: z.boolean(), + }) + ) + .mutation(async ({ ctx, input }) => { + await featuresRepository.setTeamAutoOptIn(ctx.organizationId, input.autoOptIn); + return { success: true }; + }), });
{t(config.descriptionI18nKey)}