feat: PBAC - opt-in for organizations. (#24424)

* WIP UI for opt in pbac

* Remove header

* Opt in plus route invaldiation

* Use dialog upgrade tip approach

* Add i18n + animation

* Format

* Address @designer man feedback

* Fix type errors

* Add i18n to opt in view settings header + add comments for svg

* remvoe comments
This commit is contained in:
sean-brydon
2025-10-13 09:31:53 +01:00
committed by GitHub
parent 5fd55cbfc3
commit 35d140e0a7
9 changed files with 394 additions and 15 deletions
@@ -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 = ({
</div>
<div className="my-3 space-y-px">
{tab.children?.map((child, index) => (
<VerticalTabItem
key={child.href}
name={t(child.name)}
isExternalLink={child.isExternalLink}
href={child.href || "/"}
textClassNames="text-emphasis font-medium text-sm"
className={`me-5 h-7 w-auto !px-2 ${
tab.children && index === tab.children?.length - 1 && "!mb-3"
}`}
disableChevron
/>
<div key={child.href} className="flex items-start gap-2">
<VerticalTabItem
name={t(child.name)}
isExternalLink={child.isExternalLink}
href={child.href || "/"}
textClassNames="text-emphasis font-medium text-sm"
className={`h-7 w-fit !px-2 ${
tab.children && index === tab.children?.length - 1 && "!mb-3"
}`}
disableChevron
/>
{child.isBadged && (
<Badge variant="blue" className="mt-0.5 text-xs">
New
</Badge>
)}
</div>
))}
</div>
</React.Fragment>
@@ -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<void>;
}
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
enableOverflow
type={undefined}
className="max-w-[468px] p-6"
preventCloseOnOutsideClick={true}>
<div className="flex flex-col items-center gap-8">
<div className="flex w-full flex-col items-center gap-6">
<FingerprintAnimation isHovered={isButtonHovered} />
<div className="flex w-full flex-col items-center gap-1 text-center">
<h2 className="font-cal text-emphasis text-xl font-semibold">{t("pbac_opt_in_title")}</h2>
<p className="text-default text-sm">{t("pbac_opt_in_description")}</p>
</div>
</div>
<div className="bg-subtle w-full space-y-px overflow-hidden rounded-xl border border-gray-200">
<div className="bg-default flex gap-3 p-3">
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
<Icon name="shield-check" className="text-subtle h-5 w-5" />
</div>
<div className="flex flex-col gap-1">
<p className="text-emphasis text-sm font-semibold">{t("pbac_opt_in_custom_roles_title")}</p>
<p className="text-subtle text-sm">{t("pbac_opt_in_custom_roles_desc")}</p>
</div>
</div>
<div className="bg-default flex gap-3 p-3">
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
<Icon name="lock" className="text-subtle h-5 w-5" />
</div>
<div className="flex flex-col gap-1">
<p className="text-emphasis text-sm font-semibold">
{t("pbac_opt_in_granular_permissions_title")}
</p>
<p className="text-subtle text-sm">{t("pbac_opt_in_granular_permissions_desc")}</p>
</div>
</div>
<div className="bg-default flex gap-3 p-3">
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
<Icon name="users" className="text-subtle h-5 w-5" />
</div>
<div className="flex flex-col gap-1">
<p className="text-emphasis text-sm font-semibold">
{t("pbac_opt_in_team_assignment_title")}
</p>
<p className="text-subtle text-sm">{t("pbac_opt_in_team_assignment_desc")}</p>
</div>
</div>
<div className="bg-default flex gap-3 p-3">
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
<Icon name="shield" className="text-subtle h-5 w-5" />
</div>
<div className="flex flex-col gap-1">
<p className="text-emphasis text-sm font-semibold">
{t("pbac_opt_in_enhanced_security_title")}
</p>
<p className="text-subtle text-sm">{t("pbac_opt_in_enhanced_security_desc")}</p>
</div>
</div>
</div>
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row">
<Button
color="secondary"
onClick={() => onOpenChange(false)}
disabled={enablePbacMutation.isPending}
className="w-full justify-center sm:w-1/4">
{t("cancel")}
</Button>
<Button
onClick={handleOptIn}
loading={enablePbacMutation.isPending}
className="w-full justify-center sm:w-3/4"
EndIcon="arrow-right"
onMouseEnter={() => setIsButtonHovered(true)}
onMouseLeave={() => setIsButtonHovered(false)}>
{t("pbac_opt_in_enable_button")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -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<void>;
systemRoles: Role[];
teamId: number;
}
export function PbacOptInView({ revalidateRolesPath, systemRoles, teamId }: PbacOptInViewProps) {
const [open, setOpen] = useState(true);
const { t } = useLocale();
return (
<>
<SettingsHeader
title={t("roles_and_permissions")}
description={t("roles_and_permissions_description")}
borderInShellHeader={false}
CTA={null}>
<RolesList
teamId={teamId}
roles={systemRoles}
permissions={{
canCreate: false,
canRead: true,
canUpdate: false,
canDelete: false,
}}
isPrivate={false}
/>
</SettingsHeader>
<PbacOptInModal open={open} onOpenChange={setOpen} revalidateRolesPath={revalidateRolesPath} />
</>
);
}
@@ -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<string, string | string[] | undefined> }) => {
const t = await getTranslate();
const session = await validateUserHasOrg();
@@ -76,7 +83,18 @@ const Page = async ({ searchParams }: { searchParams: Record<string, string | st
const teamHasPBACFeature = await getCachedTeamFeature(session.user.org.id, "pbac");
if (!teamHasPBACFeature) {
return notFound();
// Get system roles for preview
const roleService = new RoleService();
const systemRoles = (await roleService.getTeamRoles(session.user.org.id)).filter(
(role) => role.type === "SYSTEM"
);
return (
<PbacOptInView
revalidateRolesPath={revalidateRolesPath}
systemRoles={systemRoles}
teamId={session.user.org.id}
/>
);
}
roleSearchParamsCache.parse(searchParams);
@@ -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.",
@@ -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<void>
* @throws Error if the feature enabling fails
*/
async enableFeatureForTeam(teamId: number, featureId: keyof AppFlags, assignedBy: string): Promise<void> {
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.
@@ -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" };
}),
});
@@ -27,6 +27,7 @@ export type VerticalTabItemProps = {
iconClassName?: string;
onClick?: (name: string) => void;
isActive?: boolean;
isBadged?: boolean;
"data-testid"?: string;
};