-
Notifications
You must be signed in to change notification settings - Fork 0
Devin/feature opt in system 1765208720 #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import { _generateMetadata } from "app/_utils"; | ||
| import { headers, cookies } from "next/headers"; | ||
| import { redirect } from "next/navigation"; | ||
|
|
||
| import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; | ||
|
|
||
| import { buildLegacyRequest } from "@lib/buildLegacyCtx"; | ||
|
|
||
| import FeaturesView from "~/settings/my-account/features-view"; | ||
|
|
||
| export const generateMetadata = async () => | ||
| await _generateMetadata( | ||
| (t) => t("features"), | ||
| (t) => t("features_description"), | ||
| undefined, | ||
| undefined, | ||
| "/settings/my-account/features" | ||
| ); | ||
|
|
||
| const Page = async () => { | ||
| const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); | ||
| const userId = session?.user?.id; | ||
| const redirectUrl = "/auth/login?callbackUrl=/settings/my-account/features"; | ||
|
|
||
| if (!userId) { | ||
| return redirect(redirectUrl); | ||
| } | ||
|
|
||
| return <FeaturesView />; | ||
| }; | ||
|
|
||
| export default Page; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { _generateMetadata, getTranslate } from "app/_utils"; | ||
|
|
||
| import { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; | ||
| import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions"; | ||
| import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; | ||
| import { MembershipRole } from "@calcom/prisma/enums"; | ||
|
|
||
| import { validateUserHasOrg } from "../actions/validateUserHasOrg"; | ||
|
|
||
| import OrganizationFeaturesView from "~/settings/organizations/organization-features-view"; | ||
|
|
||
| export const generateMetadata = async () => | ||
| await _generateMetadata( | ||
| (t) => t("features"), | ||
| (t) => t("organization_features_description"), | ||
| undefined, | ||
| undefined, | ||
| "/settings/organizations/features" | ||
| ); | ||
|
|
||
| const Page = async () => { | ||
| const t = await getTranslate(); | ||
|
|
||
| const session = await validateUserHasOrg(); | ||
|
|
||
| const { canRead } = await getResourcePermissions({ | ||
| userId: session.user.id, | ||
| teamId: session.user.profile.organizationId, | ||
| resource: Resource.Feature, | ||
| userRole: session.user.org.role, | ||
| fallbackRoles: { | ||
| read: { | ||
| roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER], | ||
| }, | ||
| update: { | ||
| roles: [MembershipRole.ADMIN, MembershipRole.OWNER], | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| if (!canRead) { | ||
| return ( | ||
| <SettingsHeader | ||
| title={t("features")} | ||
| description={t("organization_features_description")} | ||
| borderInShellHeader={true}> | ||
| <div className="border-subtle border-x px-4 py-8 sm:px-6"> | ||
| <p className="text-subtle text-sm">{t("no_permission_to_view")}</p> | ||
| </div> | ||
| </SettingsHeader> | ||
| ); | ||
| } | ||
|
|
||
| return <OrganizationFeaturesView organizationId={session.user.profile.organizationId} />; | ||
| }; | ||
|
|
||
| export default Page; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { _generateMetadata } from "app/_utils"; | ||
|
|
||
| import TeamFeaturesView from "~/settings/teams/team-features-view"; | ||
|
|
||
| export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) => | ||
| await _generateMetadata( | ||
| (t) => t("features"), | ||
| (t) => t("team_features_description"), | ||
| undefined, | ||
| undefined, | ||
| `/settings/teams/${(await params).id}/features` | ||
| ); | ||
|
|
||
| const Page = async () => { | ||
| return <TeamFeaturesView />; | ||
| }; | ||
|
Comment on lines
+14
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing Feature Permission CheckThe page for managing team features does not verify if the current user has the specific permission ( Standards
Comment on lines
+14
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing Permission CheckThe new Team Features page does not perform a specific permission check for the Standards
|
||
|
|
||
| export default Page; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| "use client"; | ||
|
|
||
| import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; | ||
| import { useLocale } from "@calcom/lib/hooks/useLocale"; | ||
| import { trpc } from "@calcom/trpc/react"; | ||
| import { SettingsToggle } from "@calcom/ui/components/form"; | ||
| import { showToast } from "@calcom/ui/components/toast"; | ||
| import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton"; | ||
|
|
||
| const SkeletonLoader = () => { | ||
| return ( | ||
| <SkeletonContainer> | ||
| <div className="border-subtle space-y-6 border-x px-4 py-8 sm:px-6"> | ||
| <SkeletonText className="h-8 w-full" /> | ||
| <SkeletonText className="h-8 w-full" /> | ||
| <SkeletonText className="h-8 w-full" /> | ||
| </div> | ||
| </SkeletonContainer> | ||
| ); | ||
| }; | ||
|
|
||
| const FeaturesView = () => { | ||
| const { t } = useLocale(); | ||
| const utils = trpc.useUtils(); | ||
|
|
||
| const { data: features, isLoading } = trpc.viewer.featureManagement.listForUser.useQuery(); | ||
|
|
||
| const setFeatureEnabledMutation = trpc.viewer.featureManagement.setUserFeatureEnabled.useMutation({ | ||
|
Comment on lines
+22
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicated Feature View ComponentsThe feature view components for user, team, and organization are nearly identical, sharing the same skeleton loader, layout structure, and rendering logic. This code duplication increases maintenance effort as UI changes must be replicated across multiple files, violating DRY principles and increasing the risk of inconsistent behavior. Standards
|
||
| onSuccess: () => { | ||
| utils.viewer.featureManagement.listForUser.invalidate(); | ||
| showToast(t("settings_updated_successfully"), "success"); | ||
| }, | ||
| onError: () => { | ||
| showToast(t("error_updating_settings"), "error"); | ||
| }, | ||
| }); | ||
|
Comment on lines
+22
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicated Feature Management UIThe Standards
|
||
|
|
||
| if (isLoading) { | ||
| return ( | ||
| <SettingsHeader | ||
| title={t("features")} | ||
| description={t("features_description")} | ||
| borderInShellHeader={true}> | ||
| <SkeletonLoader /> | ||
| </SettingsHeader> | ||
| ); | ||
| } | ||
|
|
||
| const userControlledFeatures = features?.filter((f) => f.globallyEnabled) ?? []; | ||
|
|
||
| return ( | ||
| <SettingsHeader title={t("features")} description={t("features_description")} borderInShellHeader={true}> | ||
| <div className="border-subtle border-x px-4 py-8 sm:px-6"> | ||
| {userControlledFeatures.length === 0 ? ( | ||
| <p className="text-subtle text-sm">{t("no_features_available")}</p> | ||
| ) : ( | ||
| <div className="space-y-6"> | ||
| {userControlledFeatures.map((feature) => ( | ||
| <SettingsToggle | ||
| key={feature.slug} | ||
| toggleSwitchAtTheEnd={true} | ||
| title={feature.slug} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Feature Slug Used as UI LabelThe feature's internal slug is used directly as the display title in the settings UI. This couples the internal identifier with the user-facing presentation, which is not user-friendly and is brittle against changes to the slug. A human-readable name, preferably managed via i18n keys, should be used instead to improve user experience and decouple the UI from internal implementation details. Standards
|
||
| description={feature.description || t("no_description_available")} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| disabled={setFeatureEnabledMutation.isPending} | ||
| checked={feature.enabled} | ||
| onCheckedChange={(checked) => { | ||
| setFeatureEnabledMutation.mutate({ | ||
| featureSlug: feature.slug, | ||
| enabled: checked, | ||
| }); | ||
| }} | ||
| /> | ||
| ))} | ||
| </div> | ||
| )} | ||
| </div> | ||
| </SettingsHeader> | ||
| ); | ||
| }; | ||
|
Comment on lines
+22
to
+79
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicated UI View LogicThe three new view components contain nearly identical logic for fetching data, handling loading states, and rendering feature toggles. This duplication violates the DRY principle and increases future maintenance effort, as any change will need to be applied in three places. A single, reusable component should be created that can be parameterized with the appropriate tRPC hooks and entity ID to handle all three use cases. Standards
Comment on lines
+22
to
+79
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicated UI View LogicThe three new feature view components (FeaturesView, OrganizationFeaturesView, TeamFeaturesView) share nearly identical structure and logic, including skeleton loaders, headers, and toggle rendering. This duplication violates the DRY principle, increasing maintenance effort. A single, reusable component should be created to encapsulate the common UI and accept entity-specific data-fetching logic (e.g., tRPC hooks) and identifiers as props. Standards
|
||
|
|
||
| export default FeaturesView; | ||
|
Comment on lines
+22
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicated Feature View LogicThe components Standards
Comment on lines
+1
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicate UI Component LogicThe three new feature view components for 'My Account', 'Organization', and 'Team' contain nearly identical code for rendering feature toggles, including skeleton loaders and mutation logic. This duplication violates the DRY principle, increasing future maintenance costs as changes must be manually synchronized across all three files. Consolidating this into a single, generic component that accepts entity-specific data (like IDs and tRPC hooks) would significantly improve code reusability and maintainability. Standards
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| "use client"; | ||
|
|
||
| import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; | ||
| import { useLocale } from "@calcom/lib/hooks/useLocale"; | ||
| import { trpc } from "@calcom/trpc/react"; | ||
| import { SettingsToggle } from "@calcom/ui/components/form"; | ||
| import { showToast } from "@calcom/ui/components/toast"; | ||
| import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton"; | ||
|
|
||
| interface OrganizationFeaturesViewProps { | ||
| organizationId: number; | ||
| } | ||
|
|
||
| const SkeletonLoader = () => { | ||
| return ( | ||
| <SkeletonContainer> | ||
| <div className="border-subtle space-y-6 border-x px-4 py-8 sm:px-6"> | ||
| <SkeletonText className="h-8 w-full" /> | ||
| <SkeletonText className="h-8 w-full" /> | ||
| <SkeletonText className="h-8 w-full" /> | ||
| </div> | ||
| </SkeletonContainer> | ||
| ); | ||
| }; | ||
|
|
||
| const OrganizationFeaturesView = ({ organizationId }: OrganizationFeaturesViewProps) => { | ||
| const { t } = useLocale(); | ||
| const utils = trpc.useUtils(); | ||
|
|
||
| const { data: features, isLoading } = trpc.viewer.featureManagement.listForOrganization.useQuery({ | ||
| organizationId, | ||
| }); | ||
|
|
||
| const setFeatureEnabledMutation = trpc.viewer.featureManagement.setOrganizationFeatureEnabled.useMutation({ | ||
| onSuccess: () => { | ||
| utils.viewer.featureManagement.listForOrganization.invalidate({ organizationId }); | ||
| showToast(t("settings_updated_successfully"), "success"); | ||
| }, | ||
| onError: () => { | ||
| showToast(t("error_updating_settings"), "error"); | ||
| }, | ||
| }); | ||
|
|
||
| if (isLoading) { | ||
| return ( | ||
| <SettingsHeader | ||
| title={t("features")} | ||
| description={t("organization_features_description")} | ||
| borderInShellHeader={true}> | ||
| <SkeletonLoader /> | ||
| </SettingsHeader> | ||
| ); | ||
| } | ||
|
|
||
| const orgControlledFeatures = features?.filter((f) => f.globallyEnabled) ?? []; | ||
|
|
||
| return ( | ||
| <SettingsHeader | ||
| title={t("features")} | ||
| description={t("organization_features_description")} | ||
| borderInShellHeader={true}> | ||
| <div className="border-subtle border-x px-4 py-8 sm:px-6"> | ||
| {orgControlledFeatures.length === 0 ? ( | ||
| <p className="text-subtle text-sm">{t("no_features_available")}</p> | ||
| ) : ( | ||
| <div className="space-y-6"> | ||
| {orgControlledFeatures.map((feature) => ( | ||
| <SettingsToggle | ||
| key={feature.slug} | ||
| toggleSwitchAtTheEnd={true} | ||
| title={feature.slug} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| description={feature.description || t("no_description_available")} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| disabled={setFeatureEnabledMutation.isPending} | ||
| checked={feature.enabled} | ||
| onCheckedChange={(checked) => { | ||
| setFeatureEnabledMutation.mutate({ | ||
| organizationId, | ||
| featureSlug: feature.slug, | ||
| enabled: checked, | ||
| }); | ||
| }} | ||
| /> | ||
| ))} | ||
| </div> | ||
| )} | ||
| </div> | ||
| </SettingsHeader> | ||
| ); | ||
| }; | ||
|
|
||
| export default OrganizationFeaturesView; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing Page Authorization Check
The team features page directly renders without verifying user permissions for the specified team. This inconsistency with the organization features page allows unauthorized access to team feature management UI, potentially exposing sensitive configuration information and enabling unauthorized modification attempts.
Commitable Suggestion
Standards