Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
{ name: "appearance", href: "/settings/my-account/appearance", trackingMetadata: { section: "my_account", page: "appearance" } },
{ name: "out_of_office", href: "/settings/my-account/out-of-office", trackingMetadata: { section: "my_account", page: "out_of_office" } },
{ name: "push_notifications", href: "/settings/my-account/push-notifications", trackingMetadata: { section: "my_account", page: "push_notifications" } },
{ name: "features", href: "/settings/my-account/features", trackingMetadata: { section: "my_account", page: "features" } },
// TODO
// { name: "referrals", href: "/settings/my-account/referrals" },
],
Expand Down Expand Up @@ -91,6 +92,11 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
href: "/settings/organizations/general",
trackingMetadata: { section: "organization", page: "general" },
},
{
name: "features",
href: "/settings/organizations/features",
trackingMetadata: { section: "organization", page: "features" },
},
{
name: "guest_notifications",
href: "/settings/organizations/guest-notifications",
Expand Down Expand Up @@ -531,6 +537,14 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
<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
/>
</>
)}
</CollapsibleContent>
Expand Down
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,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: validateUserHasOrg only guarantees that the user has an organizationId, not that session.user.org is non-null, so directly accessing session.user.org.role can throw a runtime error when org is missing; use optional chaining with a safe default role to avoid a null/undefined property access. [null pointer]

Severity Level: Minor ⚠️

Suggested change
userRole: session.user.org.role,
userRole: session.user.org?.role ?? MembershipRole.MEMBER,
Why it matters? ⭐

The suggestion is correct and pragmatic: if validateUserHasOrg guarantees only an organizationId but not a populated session.user.org object, accessing session.user.org.role could throw at runtime. Using optional chaining with a sensible fallback (MembershipRole.MEMBER) prevents a potential undefined property access and provides a safe default role for permission checks. MembershipRole is already imported in the file, so the change is minimal and fixes a real robustness issue rather than being purely cosmetic.

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/features/page.tsx
**Line:** 30:30
**Comment:**
	*Null Pointer: `validateUserHasOrg` only guarantees that the user has an `organizationId`, not that `session.user.org` is non-null, so directly accessing `session.user.org.role` can throw a runtime error when `org` is missing; use optional chaining with a safe default role to avoid a null/undefined property access.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.

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`
Comment on lines +5 to +11
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The generateMetadata function incorrectly types and treats params as a Promise, but in Next.js generateMetadata receives a plain params object; this mismatch can break the expected contract, confuse callers and tooling, and makes the implementation harder to reason about even though await on a non-promise currently happens to work. [type error]

Severity Level: Minor ⚠️

Suggested change
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`
export const generateMetadata = async ({ params }: { params: { id: string } }) =>
await _generateMetadata(
(t) => t("features"),
(t) => t("team_features_description"),
undefined,
undefined,
`/settings/teams/${params.id}/features`
Why it matters? ⭐

The current code types params as a Promise and uses await params. In Next.js (App Router) generateMetadata receives a plain params object (e.g. { id: string }). Treating it as a Promise is incorrect: it confuses readers, can break type-checking, and forces an unnecessary await. Changing the type to { id: string } and using params.id aligns with the framework contract and removes the needless await. This is a real correctness/type issue, not mere style.

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx
**Line:** 5:11
**Comment:**
	*Type Error: The `generateMetadata` function incorrectly types and treats `params` as a `Promise`, but in Next.js `generateMetadata` receives a plain `params` object; this mismatch can break the expected contract, confuse callers and tooling, and makes the implementation harder to reason about even though `await` on a non-promise currently happens to work.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.

);

const Page = async () => {
return <TeamFeaturesView />;
};
Comment on lines +14 to +16
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add server-side authentication and authorization checks.

Unlike the user-level features page (my-account/features/page.tsx lines 20-27), this team features page lacks authentication and authorization validation. Server components should verify:

  1. User is authenticated (session exists)
  2. User has permission to view/manage features for this specific team

Relying solely on client-side checks or route middleware may not be sufficient for sensitive settings pages.

🔎 Recommended server-side auth pattern

Based on the my-account features page implementation:

+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 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 () => {
+const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
+  const { id: teamId } = await params;
+  const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+  
+  if (!session?.user?.id) {
+    return redirect(`/auth/login?callbackUrl=/settings/teams/${teamId}/features`);
+  }
+  
+  // TODO: Add team membership/permission check here
+  // e.g., verify session.user has access to this teamId
+  
   return <TeamFeaturesView />;
 };


export default Page;
81 changes: 81 additions & 0 deletions apps/web/modules/settings/my-account/features-view.tsx
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({
onSuccess: () => {
utils.viewer.featureManagement.listForUser.invalidate();
showToast(t("settings_updated_successfully"), "success");
},
onError: () => {
showToast(t("error_updating_settings"), "error");
},
});

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}
Comment on lines +52 to +60
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

View Logic Duplication

The UI rendering logic for feature toggles is duplicated across user, team, and organization views. Extracting a shared FeatureSettingsList component would reduce code duplication and centralize UI updates.

Standards
  • Clean-Code-DRY
  • Maintainability-Quality-Component-Reuse

toggleSwitchAtTheEnd={true}
title={feature.slug}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using the feature slug directly as the title in the UI isn't very user-friendly, as slugs are often designed for programmatic use. This is also done in organization-features-view.tsx and team-features-view.tsx.

Consider adding a name or title field to the Feature model in the database to store a human-readable name for each feature. This would significantly improve the user experience on these new settings pages.

description={feature.description || t("no_description_available")}
disabled={setFeatureEnabledMutation.isPending}
checked={feature.enabled}
onCheckedChange={(checked) => {
setFeatureEnabledMutation.mutate({
featureSlug: feature.slug,
enabled: checked,
});
}}
/>
))}
</div>
)}
</div>
</SettingsHeader>
);
};

export default FeaturesView;
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}
description={feature.description || t("no_description_available")}
disabled={setFeatureEnabledMutation.isPending}
checked={feature.enabled}
onCheckedChange={(checked) => {
setFeatureEnabledMutation.mutate({
organizationId,
featureSlug: feature.slug,
enabled: checked,
});
}}
/>
))}
</div>
)}
</div>
</SettingsHeader>
);
};

export default OrganizationFeaturesView;
Loading