5c686d90f9
* 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>
91 lines
3.3 KiB
TypeScript
91 lines
3.3 KiB
TypeScript
"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,
|
|
};
|
|
}
|