feat: add tri-state UI settings pages and auto opt-in preference (#25892)

* feat: add tri-state UI settings pages and auto opt-in preference

TASK 5: Tri-state UI with ToggleGroup
- Create shared TeamOrgFeaturesSettings component
- Create user features settings page (features-view.tsx)
- Create team features settings page
- Create org features settings page

TASK 6: Add auto opt-in preference
- Add autoOptInExperimentalFeatures field to User and Team models
- Add migration for new fields
- Add TRPC endpoints for auto opt-in preference (using repository pattern)
- Add auto opt-in checkboxes to settings pages

Also adds i18n keys for feature opt-in UI.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

refactor: rename autoOptInExperimentalFeatures to autoOptInFeatures

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

refactor: apply PBAC procedure pattern to featureOptIn router

- Create createTeamPbacProcedure for team-scoped endpoints
- Create createOrgPbacProcedure for organization-scoped endpoints
- Refactor all team/org endpoints to use the new PBAC procedures
- Significantly reduces boilerplate code for permission checks

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

add features pages

implement hooks

update style

update style

rename slug to featureId

show Features page on the side bar only if OPT_IN_FEATURES.length > 0

revert unncessary renaming

revert some changes

remove some changes

* fix repository usage

* test: add unit tests for PBAC utility procedures

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat: add 'disabled by organization' badge for Team Level Features page

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* a little clean up

* feat: add effectiveReason to computeEffectiveStateAcrossTeams for better UI feedback

- Update computeEffectiveStateAcrossTeams to return both enabled state and reason
- Add EffectiveStateReason type with 6 possible values
- Update FeatureOptInService to include effectiveReason in resolved state
- Simplify useUserFeatureOptIn.getBlockedWarning to use effectiveReason directly
- Add feature_no_explicit_enablement_warning translation
- Invalidate feature list when auto-opt-in setting changes in all hooks

* test: add tests for FeatureOptInService.listFeaturesForTeam and refactor router to use TeamRepository

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* make it clearer about auto opt in

* move hooks to apps/web/module

* fix

* fix: move types to packages/features/feature-opt-in, fix imports and test mocks

- Move types.ts from apps/web/modules/feature-opt-in/hooks to packages/features/feature-opt-in
- Update FeaturesSettings.tsx to import types from new location
- Update hooks to import types from @calcom/features/feature-opt-in/types
- Fix organization features-view import path (~/settings/organizations -> ~/ee/organizations)
- Fix PermissionCheckService mock in util.test.ts to use class syntax

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: add validation for team ID and remove isPublic from featureOptIn handler

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: resolve biome lint errors in feature-opt-in files

- Add explicit return types to functions and methods
- Replace ternary operators with if-else statements
- Replace forEach with for...of loops to avoid useIterableCallbackReturn
- Extract helper functions to reduce function complexity
- Add explicit type annotations to variables
- Move exports to end of file to satisfy useExportsLast rule

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: resolve type errors in featureOptIn router

- Replace ctx.organizationId with ctx.user.organization.id
- Add null checks for organizationId in org procedures
- Remove incorrect type annotation from featureOptInRouter

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: address PR feedback on feature opt-in implementation

- Refactor createMutationCallbacks to useMutationCallbacks hook with useLocale internally
- Restore comment on featureOptIn prop in FeaturesSettings.tsx
- Revert getAllTeamIds helper, use ternary operator instead
- Use ctx.user.organizationId with proper guard clauses

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix type error

* feat: disable toggle when feature is blocked by higher level

- Add isBlockedByHigherLevel function to UseFeatureOptInResult interface
- Implement blocking detection for user level (org/team disabled)
- Implement blocking detection for team level (org disabled)
- Organization level never blocked (top level)
- Update FeaturesSettings to disable ToggleGroup when blocked

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* change order of toggle buttons

* feat: add policy field (permissive/strict) to feature opt-in config

- Add OptInFeaturePolicy type with 'permissive' and 'strict' modes
- Update computeEffectiveStateAcrossTeams to handle both policies:
  - Permissive: user opt-in can activate; disables only win if ALL teams disable
  - Strict: user opt-in alone not enough; ANY explicit disable blocks
- Add new EffectiveStateReason values for strict policy
- Update FeatureOptInService to read policy from config
- Add unit tests for all 9 policy scenarios from spec

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: rewrite test file with scenario tables and make policy required

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix type

* fix types

* make policy required

* disable toggle group if blocked by higher level

* refactor: move PBAC procedures to packages/trpc/server/procedures

- Move util.ts to pbacProcedures.ts in procedures folder for better reusability
- Move util.test.ts to pbacProcedures.test.ts alongside the main file
- Update imports in _router.ts to use new location

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: remove redundant null checks in featureOptIn router

The createOrgPbacProcedure middleware already validates organizationId
and throws if null. Use non-null assertion (!) instead of redundant
runtime checks since the middleware guarantees the value exists.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: improve middleware typing to avoid non-null assertions

Remove explicit return type from createOrgPbacProcedure to let TypeScript
infer the extended context type. This allows handlers to use ctx.organizationId
directly (typed as number) instead of ctx.user.organizationId! assertions.

The middleware validates organizationId and adds it to the context, so
downstream handlers can safely access ctx.organizationId as a number.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat(pbac): use featureOptIn permissions instead of generic team/org permissions

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat(pbac): add PBAC to team features page and pass canEdit to views

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: add effectiveReason to IFeatureOptInService and update listFeaturesForTeam interface

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: add feature_any_team_disabled to isUserBlockedByHigherLevel check

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: add strict-policy reasons to warning helper function

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
This commit is contained in:
Eunjae Lee
2026-01-13 15:37:53 +01:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> sean-brydon
parent 9ad8aa981a
commit 5c686d90f9
25 changed files with 2185 additions and 468 deletions
@@ -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<number, T
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
{HAS_OPT_IN_FEATURES && (
<VerticalTabItem
name={t("features")}
href={`/settings/teams/${team.id}/features`}
textClassNames="px-3 text-emphasis font-medium text-sm"
trackingMetadata={{ section: "team", page: "features", teamId: team.id }}
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
)}
{/* Hide if there is a parent ID */}
{!team.parentId ? (
<>
@@ -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<Metadata> =>
await _generateMetadata(
(t) => t("features"),
(t) => t("feature_opt_in_description"),
undefined,
undefined,
"/settings/my-account/features"
);
const Page = (): ReactElement => {
return <FeaturesView />;
};
export { generateMetadata };
export default Page;
@@ -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<Metadata> =>
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 <OrganizationFeaturesView canEdit={canEdit} />;
};
export default Page;
@@ -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<Metadata> =>
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 <TeamFeaturesView teamId={teamId} canEdit={canEdit} />;
};
export default Page;
@@ -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 (
<SettingsHeader
title={t("features")}
description={t("feature_opt_in_org_description")}
borderInShellHeader={true}>
<FeaturesSettings featureOptIn={featureOptIn} canEdit={canEdit} />
</SettingsHeader>
);
};
export default OrganizationFeaturesView;
@@ -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";
@@ -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,
};
}
@@ -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<string, FeatureBlockingState> {
const map = new Map<string, FeatureBlockingState>();
for (const feature of data ?? []) {
map.set(feature.featureId, { orgState: feature.orgState });
}
return map;
}
function createTeamBlockedWarningFn(
blockingStateMap: Map<string, FeatureBlockingState>,
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<string, FeatureBlockingState>
): (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,
};
}
@@ -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,
};
}
@@ -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 (
<SettingsHeader
title={t("features")}
description={t("feature_opt_in_description")}
borderInShellHeader={true}>
<FeaturesSettings featureOptIn={featureOptIn} />
</SettingsHeader>
);
};
export default FeaturesView;
@@ -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 (
<SettingsHeader
title={t("features")}
description={t("feature_opt_in_team_description")}
borderInShellHeader={true}>
<FeaturesSettings featureOptIn={featureOptIn} canEdit={canEdit} />
</SettingsHeader>
);
};
export default TeamFeaturesView;
@@ -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);
@@ -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",
@@ -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 (
<div className="border-subtle rounded-b-lg border-x border-b px-4 py-8 sm:px-6">
<div className="space-y-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
</div>
);
}
function EmptyState({ message }: { message: string }): ReactElement {
return <Alert severity="neutral" title={message} />;
}
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 (
<div key={feature.slug}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3
className={classNames(
"text-emphasis flex items-center text-sm font-medium",
blockedWarning && "text-subtle line-through"
)}>
{t(config.titleI18nKey)}
{blockedWarning && (
<Tooltip side="top" content={blockedWarning}>
<span>
<Icon name="circle-alert" className="text-error ml-1 h-4 w-4" />
</span>
</Tooltip>
)}
{enabledViaAutoOptInFlag && (
<Tooltip side="top" content={t("enabled_via_auto_opt_in")}>
<span>
<Icon name="circle-check" className="text-success ml-1 h-4 w-4" />
</span>
</Tooltip>
)}
</h3>
<p className="text-subtle text-sm">{t(config.descriptionI18nKey)}</p>
</div>
<ToggleGroup
value={feature.currentState}
onValueChange={(val: string | undefined): void =>
handleValueChange(val, feature.slug, setFeatureState)
}
options={finalToggleOptions}
/>
</div>
</div>
);
}
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 <LoadingSkeleton />;
const toggleOptions: ToggleOption[] = [
{ value: "disabled", label: toggleLabels.disabled },
{ value: "enabled", label: toggleLabels.enabled },
{ value: "inherit", label: toggleLabels.inherit },
];
return (
<>
<div className="border-subtle rounded-b-lg border-x border-b px-4 py-8 sm:px-6">
{features.length === 0 && <EmptyState message={t("no_opt_in_features_available")} />}
{features.length > 0 && (
<div className="space-y-6">
{features.map((feature) => (
<FeatureItem
key={feature.slug}
feature={feature}
toggleOptions={toggleOptions}
getBlockedWarning={getBlockedWarning}
isBlockedByHigherLevel={isBlockedByHigherLevel}
autoOptIn={autoOptIn}
setFeatureState={setFeatureState}
canEdit={canEdit}
t={t}
/>
))}
</div>
)}
</div>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("auto_opt_in_experimental")}
description={autoOptInDescription}
disabled={isAutoOptInMutationPending || !canEdit}
checked={autoOptIn}
onCheckedChange={setAutoOptIn}
switchContainerClassName="mt-6"
/>
</>
);
}
export default FeaturesSettings;
+9 -1
View File
@@ -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;
@@ -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",
});
});
});
});
@@ -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" };
}
@@ -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<typeof vi.fn>;
getTeamsFeatureStates: ReturnType<typeof vi.fn>;
};
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"],
});
});
});
});
@@ -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<number, FeatureState>): FeatureState {
if (orgId !== null) {
return teamStatesById[orgId] ?? "inherit";
}
return "inherit";
}
function getOrgAutoOptIn(orgId: number | null, teamsAutoOptIn: Record<number, boolean>): 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<string, Record<number, FeatureState>>,
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<Record<string, ResolvedFeatureState>> {
// 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<string, ResolvedFeatureState> = {};
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<string, boolean>,
allTeamStates: Record<string, Record<number, FeatureState>>,
userStates: Record<string, FeatureState>,
teamsAutoOptIn: Record<number, boolean>,
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<ListFeaturesForUserResult[]> {
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<ListFeaturesForTeamResult[]> {
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<void> {
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<void> {
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({
@@ -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 }
+70
View File
@@ -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;
}
+1
View File
@@ -19,6 +19,7 @@ export const ENDPOINTS = [
"eventTypesHeavy",
"features",
"holidays",
"featureOptIn",
"i18n",
"insights",
"me",
@@ -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<typeof vi.fn> & { mockReturnThis: () => void };
use: ReturnType<typeof vi.fn>;
};
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"],
});
});
});
});
});
});
@@ -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<typeof authedProcedure.input> {
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 };
@@ -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 };
}),
});