diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx
index 792ec1f01b..39b11ab8c1 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx
@@ -221,8 +221,7 @@ const useTabs = ({
});
}
- // Add pbac menu item only if feature flag is enabled AND user has permission to view roles
- // This prevents showing the menu item when user has no organization permissions
+ // Add pbac menu item - show opt-in page if not enabled, regular page if enabled
if (isPbacEnabled) {
if (permissions?.canViewRoles) {
newArray.push({
@@ -244,6 +243,14 @@ const useTabs = ({
href: "/settings/organizations/billing",
});
}
+ // Show roles page (modal will appear if PBAC not enabled)
+ if (permissions?.canUpdateOrganization) {
+ newArray.push({
+ name: "roles",
+ href: "/settings/organizations/roles",
+ isBadged: true, // Show "New" badge
+ });
+ }
}
return {
@@ -604,17 +611,23 @@ const SettingsSidebarContainer = ({
{tab.children?.map((child, index) => (
-
+
+
+ {child.isBadged && (
+
+ New
+
+ )}
+
))}
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/FingerprintAnimation.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/FingerprintAnimation.tsx
new file mode 100644
index 0000000000..da28eb661f
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/FingerprintAnimation.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { motion } from "framer-motion";
+
+interface FingerprintAnimationProps {
+ isHovered: boolean;
+}
+
+export function FingerprintAnimation({ isHovered }: FingerprintAnimationProps) {
+ return (
+
+
+ {/*Fingerprint*/}
+
+ {/*Clip path to contain animated scanning line*/}
+
+
+
+
+ {/* Animated scanning line */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/PbacOptInModal.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/PbacOptInModal.tsx
new file mode 100644
index 0000000000..efaea77903
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/PbacOptInModal.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { trpc } from "@calcom/trpc/react";
+import { Button } from "@calcom/ui/components/button";
+import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
+import { Icon } from "@calcom/ui/components/icon";
+import { showToast } from "@calcom/ui/components/toast";
+
+import { FingerprintAnimation } from "./FingerprintAnimation";
+
+interface PbacOptInModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ revalidateRolesPath: () => Promise;
+}
+
+export function PbacOptInModal({ open, onOpenChange, revalidateRolesPath }: PbacOptInModalProps) {
+ const router = useRouter();
+ const { t } = useLocale();
+ const [isButtonHovered, setIsButtonHovered] = useState(false);
+
+ const enablePbacMutation = trpc.viewer.pbac.enablePbac.useMutation({
+ onSuccess: async () => {
+ showToast(t("pbac_enabled_success"), "success");
+ await revalidateRolesPath();
+ onOpenChange(false);
+ router.refresh();
+ },
+ onError: (error) => {
+ showToast(error.message || t("pbac_enabled_error"), "error");
+ },
+ });
+
+ const handleOptIn = () => {
+ enablePbacMutation.mutate();
+ };
+
+ return (
+
+
+
+
+
+
+
+
{t("pbac_opt_in_title")}
+
{t("pbac_opt_in_description")}
+
+
+
+
+
+
+
+
+
+
{t("pbac_opt_in_custom_roles_title")}
+
{t("pbac_opt_in_custom_roles_desc")}
+
+
+
+
+
+
+
+
+
+ {t("pbac_opt_in_granular_permissions_title")}
+
+
{t("pbac_opt_in_granular_permissions_desc")}
+
+
+
+
+
+
+
+
+
+ {t("pbac_opt_in_team_assignment_title")}
+
+
{t("pbac_opt_in_team_assignment_desc")}
+
+
+
+
+
+
+
+
+
+ {t("pbac_opt_in_enhanced_security_title")}
+
+
{t("pbac_opt_in_enhanced_security_desc")}
+
+
+
+
+
+ onOpenChange(false)}
+ disabled={enablePbacMutation.isPending}
+ className="w-full justify-center sm:w-1/4">
+ {t("cancel")}
+
+ setIsButtonHovered(true)}
+ onMouseLeave={() => setIsButtonHovered(false)}>
+ {t("pbac_opt_in_enable_button")}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/PbacOptInView.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/PbacOptInView.tsx
new file mode 100644
index 0000000000..b5031379aa
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/PbacOptInView.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { useState } from "react";
+
+import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+
+import { PbacOptInModal } from "./PbacOptInModal";
+import { RolesList } from "./RolesList";
+
+type Role = {
+ id: string;
+ name: string;
+ description?: string;
+ teamId?: number;
+ color?: string;
+ createdAt: Date;
+ updatedAt: Date;
+ type: "SYSTEM" | "CUSTOM";
+ permissions: {
+ id: string;
+ resource: string;
+ action: string;
+ }[];
+};
+
+interface PbacOptInViewProps {
+ revalidateRolesPath: () => Promise;
+ systemRoles: Role[];
+ teamId: number;
+}
+
+export function PbacOptInView({ revalidateRolesPath, systemRoles, teamId }: PbacOptInViewProps) {
+ const [open, setOpen] = useState(true);
+ const { t } = useLocale();
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx
index 97a0382843..dcd08c327e 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx
@@ -1,6 +1,7 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";
import { notFound } from "next/navigation";
+import { revalidatePath } from "next/cache";
import type { AppFlags } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
@@ -13,6 +14,7 @@ import { prisma } from "@calcom/prisma";
import { validateUserHasOrg } from "../actions/validateUserHasOrg";
import { CreateRoleCTA } from "./_components/CreateRoleCta";
+import { PbacOptInView } from "./_components/PbacOptInView";
import { RolesList } from "./_components/RolesList";
import { roleSearchParamsCache } from "./_components/searchParams";
@@ -65,6 +67,11 @@ export const generateMetadata = async () =>
"/settings/organizations/roles"
);
+async function revalidateRolesPath() {
+ "use server";
+ revalidatePath("/settings/organizations/roles");
+}
+
const Page = async ({ searchParams }: { searchParams: Record }) => {
const t = await getTranslate();
const session = await validateUserHasOrg();
@@ -76,7 +83,18 @@ const Page = async ({ searchParams }: { searchParams: Record role.type === "SYSTEM"
+ );
+ return (
+
+ );
}
roleSearchParamsCache.parse(searchParams);
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 2d0cc31075..8638f300fd 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -3463,6 +3463,7 @@
"requires_credits_tooltip": "You need enough credits in your account to use this feature",
"available_credits": "Available credits",
"create_role": "Create role",
+ "roles": "Roles",
"roles_and_permissions": "Roles & Permissions",
"roles_and_permissions_description": "Manage roles and permissions for your organization",
"learn_more_permissions": "Learn more about how Permissions work",
@@ -3576,6 +3577,19 @@
"pbac_desc_view_routing_forms": "View routing forms",
"pbac_desc_update_routing_forms": "Update routing forms",
"pbac_desc_delete_routing_forms": "Delete routing forms",
+ "pbac_opt_in_title": "Enable Roles & Permissions for your organization",
+ "pbac_opt_in_description": "Take control of your organization with customizable roles and granular permissions for your team members.",
+ "pbac_opt_in_custom_roles_title": "Custom roles",
+ "pbac_opt_in_custom_roles_desc": "Create custom roles tailored to your organization's unique workflow",
+ "pbac_opt_in_granular_permissions_title": "Granular permissions",
+ "pbac_opt_in_granular_permissions_desc": "Control exactly what team members can view, edit, and manage",
+ "pbac_opt_in_team_assignment_title": "Team member assignment",
+ "pbac_opt_in_team_assignment_desc": "Easily assign roles to team members as your team grows",
+ "pbac_opt_in_enhanced_security_title": "Enhanced security",
+ "pbac_opt_in_enhanced_security_desc": "Protect sensitive data with organization-wide security controls",
+ "pbac_opt_in_enable_button": "Enable PBAC",
+ "pbac_enabled_success": "Successfully enabled PBAC for your organization",
+ "pbac_enabled_error": "Failed to enable PBAC for your organization",
"read_permission_auto_enabled_tooltip": "Read permission is automatically enabled when creating, updating, or deleting a resource",
"choose_restriction_schedule": "Add restriction schedule",
"restriction_schedule_description": "Limit availability of hosts to only display slots that fall within a specified schedule.",
diff --git a/packages/features/flags/features.repository.ts b/packages/features/flags/features.repository.ts
index 3781766f65..c4baefb776 100644
--- a/packages/features/flags/features.repository.ts
+++ b/packages/features/flags/features.repository.ts
@@ -190,6 +190,38 @@ export class FeaturesRepository implements IFeaturesRepository {
}
}
+ /**
+ * Enables a feature for a specific team.
+ * @param teamId - The ID of the team to enable the feature for
+ * @param featureId - The feature identifier to enable
+ * @param assignedBy - The user or what assigned the feature
+ * @returns Promise
+ * @throws Error if the feature enabling fails
+ */
+ async enableFeatureForTeam(teamId: number, featureId: keyof AppFlags, assignedBy: string): Promise {
+ try {
+ await this.prismaClient.teamFeatures.upsert({
+ where: {
+ teamId_featureId: {
+ teamId,
+ featureId,
+ },
+ },
+ create: {
+ teamId,
+ featureId,
+ assignedBy,
+ },
+ update: {},
+ });
+ // Clear cache when features are modified
+ this.clearCache();
+ } catch (err) {
+ captureException(err);
+ throw err;
+ }
+ }
+
/**
* Checks if a team or any of its ancestors has access to a specific feature.
* Uses a recursive CTE raw SQL query for performance.
diff --git a/packages/trpc/server/routers/viewer/pbac/_router.tsx b/packages/trpc/server/routers/viewer/pbac/_router.tsx
index 819f90443f..692ac3a032 100644
--- a/packages/trpc/server/routers/viewer/pbac/_router.tsx
+++ b/packages/trpc/server/routers/viewer/pbac/_router.tsx
@@ -5,7 +5,7 @@ import { isValidPermissionString } from "@calcom/features/pbac/domain/types/perm
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { RoleService } from "@calcom/features/pbac/services/role.service";
-import prisma from "@calcom/prisma";
+import { prisma } from "@calcom/prisma";
import { RoleType, MembershipRole } from "@calcom/prisma/enums";
import authedProcedure from "../../../procedures/authedProcedure";
@@ -173,6 +173,7 @@ export const permissionsRouter = router({
.input(
z.object({
teamId: z.number(),
+ includeSystemRolesOnly: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
@@ -183,6 +184,14 @@ export const permissionsRouter = router({
const featureRepo = new FeaturesRepository(prisma);
const teamHasPBACFeature = await featureRepo.checkIfTeamHasFeature(input.teamId, "pbac");
+ // If PBAC is not enabled but caller wants system roles only (for preview), allow it
+ if (!teamHasPBACFeature && input.includeSystemRolesOnly) {
+ const roleService = new RoleService();
+ const allRoles = await roleService.getTeamRoles(input.teamId);
+ // Filter to only return system roles for preview
+ return allRoles.filter((role) => role.type === "SYSTEM");
+ }
+
if (!teamHasPBACFeature) {
throw new Error("PBAC is not enabled for this team");
}
@@ -203,4 +212,45 @@ export const permissionsRouter = router({
const roleService = new RoleService();
return roleService.getTeamRoles(input.teamId);
}),
+
+ enablePbac: authedProcedure.mutation(async ({ ctx }) => {
+ if (!ctx.user?.id) {
+ throw new Error("Unauthorized");
+ }
+
+ // Check for organization ID
+ const orgId = ctx.user.organizationId;
+ if (!orgId) {
+ throw new Error("No organization found for user");
+ }
+
+ // Check if user is ADMIN or OWNER of the organization
+ const membership = await prisma.membership.findFirst({
+ where: {
+ userId: ctx.user.id,
+ teamId: orgId,
+ accepted: true,
+ role: {
+ in: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ },
+ });
+
+ if (!membership) {
+ throw new Error("You must be an ADMIN or OWNER to enable PBAC for this organization");
+ }
+
+ // Check if PBAC is already enabled for this org
+ const featureRepo = new FeaturesRepository(prisma);
+ const pbacAlreadyEnabled = await featureRepo.checkIfTeamHasFeature(orgId, "pbac");
+
+ if (pbacAlreadyEnabled) {
+ return { success: true, message: "PBAC is already enabled for this organization" };
+ }
+
+ // Enable PBAC feature for the organization
+ await featureRepo.enableFeatureForTeam(orgId, "pbac", "opt-in by user: " + ctx.user.id);
+
+ return { success: true, message: "PBAC enabled successfully" };
+ }),
});
diff --git a/packages/ui/components/navigation/tabs/VerticalTabItem.tsx b/packages/ui/components/navigation/tabs/VerticalTabItem.tsx
index 2d3da4b7c6..8f6095ef7c 100644
--- a/packages/ui/components/navigation/tabs/VerticalTabItem.tsx
+++ b/packages/ui/components/navigation/tabs/VerticalTabItem.tsx
@@ -27,6 +27,7 @@ export type VerticalTabItemProps = {
iconClassName?: string;
onClick?: (name: string) => void;
isActive?: boolean;
+ isBadged?: boolean;
"data-testid"?: string;
};