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:
+26
-13
@@ -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>
|
||||
|
||||
+63
File diff suppressed because one or more lines are too long
+129
@@ -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>
|
||||
);
|
||||
}
|
||||
+59
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+19
-1
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user