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")}

+
+
+
+ +
+ + +
+
+
+
+ ); +} 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; };