From 8006c490cf9f1c83dba3fa6d10cb8a27a33f715a Mon Sep 17 00:00:00 2001 From: lina-alomari Date: Sun, 27 Sep 2026 12:52:05 +0300 Subject: [PATCH 1/3] feat(web): rename the roles tab and rebuild the roles card (#15697) Co-authored-by: Claude Opus 5 (1M context) --- .../web/public/locales/en/translation.json | 25 ++- .../components/global-search/static-pages.ts | 2 +- .../app/components/sidebar/platform/index.tsx | 2 +- .../web/src/app/routes/platform-routes.tsx | 2 +- .../routes/platform/plan-feature-sample.tsx | 2 +- .../platform/security/project-role/index.tsx | 49 +----- .../project-role/platform-roles-list.tsx | 64 +++++++ .../project-role/project-role-dialog.tsx | 52 +++++- .../project-role/project-roles-list.tsx | 149 ++++++++++++++++ .../project-role/project-roles-table.tsx | 161 ------------------ .../security/project-role/role-avatar.tsx | 19 +++ .../security/project-role/roles-card.tsx | 118 +++++++++++++ .../members/components/role-selector.tsx | 54 ++---- .../web/src/features/members/lib/role-copy.ts | 109 ++++++++++++ 14 files changed, 549 insertions(+), 259 deletions(-) create mode 100644 packages/web/src/app/routes/platform/security/project-role/platform-roles-list.tsx create mode 100644 packages/web/src/app/routes/platform/security/project-role/project-roles-list.tsx delete mode 100644 packages/web/src/app/routes/platform/security/project-role/project-roles-table.tsx create mode 100644 packages/web/src/app/routes/platform/security/project-role/role-avatar.tsx create mode 100644 packages/web/src/app/routes/platform/security/project-role/roles-card.tsx create mode 100644 packages/web/src/features/members/lib/role-copy.ts diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index a6d9e07c205b..ba015defbce2 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -738,6 +738,24 @@ "Security": "Security", "Single Sign On": "Single Sign On", "Project Roles": "Project Roles", + "Roles & Access": "Roles & Access", + "Full access": "Full access", + "Build, publish, and manage flows": "Build, publish, and manage flows", + "View only": "View only", + "Access to the team projects they're invited to": "Access to the team projects they're invited to", + "What new members get, and what each role can do.": "What new members get, and what each role can do.", + "Two kinds. A platform role is one per person and decides console access and which projects they see. A project role is chosen per project and decides what they can do inside it.": "Two kinds. A platform role is one per person and decides console access and which projects they see. A project role is chosen per project and decides what they can do inside it.", + "Platform roles": "Platform roles", + "New role": "New role", + "Built in": "Built in", + "Press a row to open the role. Press the people count to see who has it. Edit and delete live inside the role, so the list stays quiet.": "Press a row to open the role. Press the people count to see who has it. Edit and delete live inside the role, so the list stays quiet.", + "Everyone has exactly one. To change someone's, open": "Everyone has exactly one. To change someone's, open", + "Every project as Admin, plus this console": "Every project as Admin, plus this console", + "Every project as Editor, no console": "Every project as Editor, no console", + "Own project, plus the projects they're added to": "Own project, plus the projects they're added to", + "Default for new people": "Default for new people", + "rolePeopleCount": "{count, plural, =1 {1 person} other {# people}}", + "roles": "roles", "API Keys": "API Keys", "Secret Managers": "Secret Managers", "Observability": "Observability", @@ -1354,9 +1372,7 @@ "Deleting this signing key will invalidate any tokens signed with it.": "Deleting this signing key will invalidate any tokens signed with it.", "New Role": "New Role", "Contact sales to unlock custom roles": "Contact sales to unlock custom roles", - "Project Role Management": "Project Role Management", "Define custom roles and permissions to control what your team members can access and modify": "Define custom roles and permissions to control what your team members can access and modify", - "Define custom roles and permissions that can be assigned to your team members": "Define custom roles and permissions that can be assigned to your team members", "Create Role": "Create Role", "View Role: {name}": "View Role: {name}", "Edit Role: {name}": "Edit Role: {name}", @@ -1369,7 +1385,6 @@ "Start by assigning users to this role": "Start by assigning users to this role", "No project roles yet. Create one to get started.": "No project roles yet. Create one to get started.", "Custom": "Custom", - "1 user": "1 user", "Delete Role": "Delete Role", "Deleting this role will remove {count} project member(s) and all associated invitations.": "Deleting this role will remove {count} project member(s) and all associated invitations.", "Project Role": "Project Role", @@ -1398,6 +1413,7 @@ "Email authentication updated": "Email authentication updated", "Google authentication updated": "Google authentication updated", "Enable Single Sign On": "Enable Single Sign On", + "Enable Custom Roles": "Enable Custom Roles", "Let your users sign in with your current SSO provider or give them self serve sign up access": "Let your users sign in with your current SSO provider or give them self serve sign up access", "Manage single sign on providers": "Manage single sign on providers", "Allowed Domains": "Allowed Domains", @@ -2429,9 +2445,6 @@ "Full access to all projects and platform settings": "Full access to all projects and platform settings", "Access and edit flows in all projects, no platform settings": "Access and edit flows in all projects, no platform settings", "Access to personal project and any team projects they're invited to": "Access to personal project and any team projects they're invited to", - "Manage project settings, members, connections, and git sync": "Manage project settings, members, connections, and git sync", - "Build, publish, and manage flows": "Build, publish, and manage flows", - "View flows and monitor run history": "View flows and monitor run history", "Platform Roles": "Platform Roles", "Select Role": "Select Role", "Roles": "Roles", diff --git a/packages/web/src/app/components/global-search/static-pages.ts b/packages/web/src/app/components/global-search/static-pages.ts index 2e13806d6f7d..a0ff86883c7b 100644 --- a/packages/web/src/app/components/global-search/static-pages.ts +++ b/packages/web/src/app/components/global-search/static-pages.ts @@ -171,7 +171,7 @@ export const STATIC_PAGES: StaticPage[] = [ }, { id: 'page-platform-project-roles', - label: 'Platform Admin — Project Roles', + label: 'Platform Admin — Roles & Access', href: '/platform/users/roles', icon: Settings2Icon, requiresPlatformAdmin: true, diff --git a/packages/web/src/app/components/sidebar/platform/index.tsx b/packages/web/src/app/components/sidebar/platform/index.tsx index aad9d3f02a3f..a2280729b3aa 100644 --- a/packages/web/src/app/components/sidebar/platform/index.tsx +++ b/packages/web/src/app/components/sidebar/platform/index.tsx @@ -74,7 +74,7 @@ export function PlatformSidebar() { { to: '/platform/users', label: t('Members'), end: true }, { to: '/platform/users/roles', - label: t('Project Roles'), + label: t('Roles & Access'), locked: !platform.plan.projectRolesEnabled, tier: PLATFORM_FEATURES.projectRoles.tier, }, diff --git a/packages/web/src/app/routes/platform-routes.tsx b/packages/web/src/app/routes/platform-routes.tsx index 4e5a64ca2197..0f385fb036aa 100644 --- a/packages/web/src/app/routes/platform-routes.tsx +++ b/packages/web/src/app/routes/platform-routes.tsx @@ -416,7 +416,7 @@ export const platformRoutes = [ path: '/platform/users/roles', element: ( - + diff --git a/packages/web/src/app/routes/platform/plan-feature-sample.tsx b/packages/web/src/app/routes/platform/plan-feature-sample.tsx index ee99ba12cee3..b8be4a317d51 100644 --- a/packages/web/src/app/routes/platform/plan-feature-sample.tsx +++ b/packages/web/src/app/routes/platform/plan-feature-sample.tsx @@ -42,7 +42,7 @@ const PLAN_FEATURE_SAMPLES: Record = { isLocked: (plan) => !plan.projectRolesEnabled, teaser: { featureKey: 'CUSTOM_ROLES', - title: 'Project Role Management', + title: 'Enable Custom Roles', description: 'Define custom roles and permissions to control what your team members can access and modify', tier: 'team', diff --git a/packages/web/src/app/routes/platform/security/project-role/index.tsx b/packages/web/src/app/routes/platform/security/project-role/index.tsx index a860409d1419..784e0fa9f150 100644 --- a/packages/web/src/app/routes/platform/security/project-role/index.tsx +++ b/packages/web/src/app/routes/platform/security/project-role/index.tsx @@ -1,64 +1,31 @@ import { t } from 'i18next'; import { CenteredPage } from '@/app/components/centered-page'; -import { AnimatedIconButton } from '@/components/custom/animated-icon-button'; -import { PlusIcon } from '@/components/icons/plus'; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip'; import { projectRoleQueries } from '@/features/platform-admin'; import { platformHooks } from '@/hooks/platform-hooks'; import { sampleData } from '../../sample-data'; -import { ProjectRoleDialog } from './project-role-dialog'; -import { ProjectRolesTable } from './project-roles-table'; +import { RolesCard } from './roles-card'; const ProjectRolePage = () => { const { platform } = platformHooks.useCurrentPlatform(); - const { data, isLoading, refetch } = projectRoleQueries.useProjectRoles( - platform.plan.projectRolesEnabled, - ); + const { data, isLoading, isError, refetch } = + projectRoleQueries.useProjectRoles(platform.plan.projectRolesEnabled); const isSample = !platform.plan.projectRolesEnabled; const roles = isSample ? sampleData.projectRolesPage() : data; - const newRoleButton = !platform.plan.customRolesEnabled ? ( - - - - {t('New Role')} - - - - {t('Contact sales to unlock custom roles')} - - - ) : ( - refetch()} - platformId={platform.id} - > - - {t('New Role')} - - - ); - return ( - diff --git a/packages/web/src/app/routes/platform/security/project-role/platform-roles-list.tsx b/packages/web/src/app/routes/platform/security/project-role/platform-roles-list.tsx new file mode 100644 index 000000000000..a5921bdbcd51 --- /dev/null +++ b/packages/web/src/app/routes/platform/security/project-role/platform-roles-list.tsx @@ -0,0 +1,64 @@ +import { t } from 'i18next'; +import { Link } from 'react-router-dom'; + +import { Badge } from '@/components/ui/badge'; +import { + Item, + ItemContent, + ItemDescription, + ItemGroup, + ItemTitle, +} from '@/components/ui/item'; +import { roleCopy } from '@/features/members/lib/role-copy'; +import { platformHooks } from '@/hooks/platform-hooks'; + +import { RoleAvatar } from './role-avatar'; + +export function PlatformRolesList() { + const { platform } = platformHooks.useCurrentPlatform(); + + return ( +
+ + {roleCopy + .platformRoles({ + personalProjectsEnabled: platform.autoCreatePersonalProjects, + }) + .map((platformRole) => ( + + + + + {platformRole.label} + + {t('Built in')} + + {platformRole.isDefaultForNewMembers && ( + + {t('Default for new people')} + + )} + + {platformRole.description} + + + ))} + +

+ {t("Everyone has exactly one. To change someone's, open")}{' '} + + {t('Users')} → + +

+
+ ); +} diff --git a/packages/web/src/app/routes/platform/security/project-role/project-role-dialog.tsx b/packages/web/src/app/routes/platform/security/project-role/project-role-dialog.tsx index 98a76b9d6bf7..7373b22f9503 100644 --- a/packages/web/src/app/routes/platform/security/project-role/project-role-dialog.tsx +++ b/packages/web/src/app/routes/platform/security/project-role/project-role-dialog.tsx @@ -1,7 +1,9 @@ import { Permission, ProjectRole, RoleType } from '@activepieces/core-utils'; import { t } from 'i18next'; +import { Trash } from 'lucide-react'; import { useState, ReactNode } from 'react'; +import { ConfirmationDeleteDialog } from '@/components/custom/delete-dialog'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -115,8 +117,10 @@ interface ProjectRoleDialogProps { projectRole?: ProjectRole; platformId: string; onSave: () => void; - children: ReactNode; + children?: ReactNode; disabled?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; } export const ProjectRoleDialog = ({ @@ -125,8 +129,17 @@ export const ProjectRoleDialog = ({ onSave, children, disabled = false, + open, + onOpenChange, }: ProjectRoleDialogProps) => { - const [isOpen, setIsOpen] = useState(false); + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const isOpen = open ?? uncontrolledOpen; + const setIsOpen = (nextOpen: boolean) => { + if (open === undefined) { + setUncontrolledOpen(nextOpen); + } + onOpenChange?.(nextOpen); + }; const [roleName, setRoleName] = useState(projectRole?.name || ''); const [permissions, setPermissions] = useState(() => { if (!projectRole?.permissions) { @@ -147,6 +160,13 @@ export const ProjectRoleDialog = ({ onSave(); }, }); + const { mutate: deleteProjectRole } = + projectRoleMutations.useDeleteProjectRole({ + onSuccess: () => { + setIsOpen(false); + onSave(); + }, + }); const handlePermissionChange = (permission: string, level: string) => { const currentPermission = initialPermissions.find( @@ -221,7 +241,7 @@ export const ProjectRoleDialog = ({ return ( - {children} + {children && {children}} @@ -324,9 +344,29 @@ export const ProjectRoleDialog = ({ {!disabled && ( - +
+ {mode === 'edit' && projectRole && ( + deleteProjectRole(projectRole.name)} + > + + + )} + +
)}
diff --git a/packages/web/src/app/routes/platform/security/project-role/project-roles-list.tsx b/packages/web/src/app/routes/platform/security/project-role/project-roles-list.tsx new file mode 100644 index 000000000000..70a2ce64ef5b --- /dev/null +++ b/packages/web/src/app/routes/platform/security/project-role/project-roles-list.tsx @@ -0,0 +1,149 @@ +import { ProjectRole, RoleType, SeekPage } from '@activepieces/core-utils'; +import { isNil } from '@activepieces/shared'; +import { t } from 'i18next'; +import { ChevronRight, Shield } from 'lucide-react'; +import { useState } from 'react'; + +import { DataFetchErrorState } from '@/components/custom/data-fetch-error-state'; +import { Badge } from '@/components/ui/badge'; +import { + Item, + ItemContent, + ItemDescription, + ItemGroup, + ItemTitle, +} from '@/components/ui/item'; +import { SkeletonList } from '@/components/ui/skeleton'; +import { roleCopy } from '@/features/members/lib/role-copy'; +import { platformHooks } from '@/hooks/platform-hooks'; + +import { ProjectRoleDialog } from './project-role-dialog'; +import { ProjectRoleUsersSheet } from './project-role-users-table'; +import { RoleAvatar } from './role-avatar'; + +export function ProjectRolesList({ + projectRoles, + isLoading, + isError, + refetch, +}: ProjectRolesListProps) { + const { platform } = platformHooks.useCurrentPlatform(); + const [openedRole, setOpenedRole] = useState(null); + const [peopleRole, setPeopleRole] = useState(null); + + if (isLoading) { + return ; + } + + if (isError) { + return ; + } + + const roles = projectRoles?.data ?? []; + + if (roles.length === 0) { + return ( +
+ +

+ {t('No project roles yet. Create one to get started.')} +

+
+ ); + } + + return ( +
+ + {roles.map((role) => { + const description = roleCopy.projectRoleDescription(role.name); + return ( + + + + + + + {role.type === RoleType.DEFAULT + ? t('Built in') + : t('Custom')} + + + + {description} + {description && !isNil(role.userCount) && ' · '} + {!isNil(role.userCount) && ( + + )} + + + + + ); + })} + +

+ {t( + 'Press a row to open the role. Press the people count to see who has it. Edit and delete live inside the role, so the list stays quiet.', + )} +

+ {openedRole && ( + { + if (!open) { + setOpenedRole(null); + } + }} + onSave={() => { + setOpenedRole(null); + refetch(); + }} + disabled={openedRole.type === RoleType.DEFAULT} + /> + )} + { + if (!open) { + setPeopleRole(null); + } + }} + /> +
+ ); +} + +type ProjectRolesListProps = { + projectRoles: SeekPage | undefined; + isLoading: boolean; + isError: boolean; + refetch: () => void; +}; diff --git a/packages/web/src/app/routes/platform/security/project-role/project-roles-table.tsx b/packages/web/src/app/routes/platform/security/project-role/project-roles-table.tsx deleted file mode 100644 index 5169ce914c80..000000000000 --- a/packages/web/src/app/routes/platform/security/project-role/project-roles-table.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { ProjectRole, RoleType, SeekPage } from '@activepieces/core-utils'; -import { t } from 'i18next'; -import { - Eye, - PenLine, - Pencil, - Shield, - ShieldCheck, - Trash, - Users, -} from 'lucide-react'; -import { useState } from 'react'; - -import { ConfirmationDeleteDialog } from '@/components/custom/delete-dialog'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { - Item, - ItemActions, - ItemContent, - ItemDescription, - ItemGroup, - ItemMedia, - ItemTitle, -} from '@/components/ui/item'; -import { SkeletonList } from '@/components/ui/skeleton'; -import { projectRoleMutations } from '@/features/platform-admin'; -import { platformHooks } from '@/hooks/platform-hooks'; - -import { ProjectRoleDialog } from './project-role-dialog'; -import { ProjectRoleUsersSheet } from './project-role-users-table'; - -interface ProjectRolesListProps { - projectRoles: SeekPage | undefined; - isLoading: boolean; - refetch: () => void; -} - -function getRoleIcon(roleName: string) { - switch (roleName) { - case 'Admin': - return ; - case 'Editor': - return ; - case 'Viewer': - return ; - default: - return ; - } -} - -export const ProjectRolesTable = ({ - projectRoles, - isLoading, - refetch, -}: ProjectRolesListProps) => { - const { platform } = platformHooks.useCurrentPlatform(); - const [selectedRole, setSelectedRole] = useState(null); - const [isUsersSheetOpen, setIsUsersSheetOpen] = useState(false); - - const { mutate: deleteProjectRole } = - projectRoleMutations.useDeleteProjectRole({ - onSuccess: () => refetch(), - }); - - if (isLoading) { - return ; - } - - const roles = projectRoles?.data ?? []; - - if (roles.length === 0) { - return ( -
- -

- {t('No project roles yet. Create one to get started.')} -

-
- ); - } - - return ( - <> - - {roles.map((role) => ( - - {getRoleIcon(role.name)} - - {role.name} - - - {role.type === RoleType.DEFAULT ? t('Default') : t('Custom')} - - - - - - refetch()} - disabled={role.type === RoleType.DEFAULT} - > - - - {role.type !== RoleType.DEFAULT && ( - deleteProjectRole(role.name)} - > - - - )} - - - ))} - - - - ); -}; diff --git a/packages/web/src/app/routes/platform/security/project-role/role-avatar.tsx b/packages/web/src/app/routes/platform/security/project-role/role-avatar.tsx new file mode 100644 index 000000000000..64f56522be71 --- /dev/null +++ b/packages/web/src/app/routes/platform/security/project-role/role-avatar.tsx @@ -0,0 +1,19 @@ +import { ItemMedia } from '@/components/ui/item'; +import { cn } from '@/lib/utils'; + +export function RoleAvatar({ name, className }: RoleAvatarProps) { + return ( + + {name.trim().charAt(0).toUpperCase()} + + ); +} + +type RoleAvatarProps = { + name: string; + className?: string; +}; diff --git a/packages/web/src/app/routes/platform/security/project-role/roles-card.tsx b/packages/web/src/app/routes/platform/security/project-role/roles-card.tsx new file mode 100644 index 000000000000..2d8b35bae597 --- /dev/null +++ b/packages/web/src/app/routes/platform/security/project-role/roles-card.tsx @@ -0,0 +1,118 @@ +import { ProjectRole, SeekPage } from '@activepieces/core-utils'; +import { isNil } from '@activepieces/shared'; +import { t } from 'i18next'; +import { useState } from 'react'; + +import { AnimatedIconButton } from '@/components/custom/animated-icon-button'; +import { PlusIcon } from '@/components/icons/plus'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { platformHooks } from '@/hooks/platform-hooks'; + +import { PlatformRolesList } from './platform-roles-list'; +import { ProjectRoleDialog } from './project-role-dialog'; +import { ProjectRolesList } from './project-roles-list'; + +const PLATFORM_ROLE_COUNT = 3; + +export function RolesCard({ + projectRoles, + isLoading, + isError, + refetch, +}: RolesCardProps) { + const { platform } = platformHooks.useCurrentPlatform(); + const [activeTab, setActiveTab] = useState('project'); + + const projectRolesCount = + isLoading || isError ? null : projectRoles?.data.length ?? 0; + const platformRolesCount = PLATFORM_ROLE_COUNT; + + const newRoleButton = !platform.plan.customRolesEnabled ? ( + + + + {t('New role')} + + + + {t('Contact sales to unlock custom roles')} + + + ) : ( + refetch()} + platformId={platform.id} + > + + {t('New role')} + + + ); + + return ( +
+
+

{t('Roles')}

+

+ {t( + 'Two kinds. A platform role is one per person and decides console access and which projects they see. A project role is chosen per project and decides what they can do inside it.', + )} +

+
+ setActiveTab(toRolesTab(value))} + > +
+ + + {t('Project roles')} + {!isNil(projectRolesCount) && ( + + {projectRolesCount} + + )} + + + {t('Platform roles')} + + {platformRolesCount} + + + + {activeTab === 'project' && newRoleButton} +
+ + + + + + +
+
+ ); +} + +function toRolesTab(value: string): RolesTab { + return value === 'platform' ? 'platform' : 'project'; +} + +type RolesTab = 'project' | 'platform'; + +type RolesCardProps = { + projectRoles: SeekPage | undefined; + isLoading: boolean; + isError: boolean; + refetch: () => void; +}; diff --git a/packages/web/src/features/members/components/role-selector.tsx b/packages/web/src/features/members/components/role-selector.tsx index f81c8eb51cbd..387d426a2aa9 100644 --- a/packages/web/src/features/members/components/role-selector.tsx +++ b/packages/web/src/features/members/components/role-selector.tsx @@ -1,4 +1,3 @@ -import { PlatformRole } from '@activepieces/shared'; import { t } from 'i18next'; import { Loader2 } from 'lucide-react'; @@ -11,43 +10,11 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; - -type RoleConfig = { - value: T; - label: string; - description: string; -}; - -const PLATFORM_ROLES: RoleConfig[] = [ - { - value: PlatformRole.ADMIN, - label: t('Admin'), - description: t('Full access to all projects and platform settings'), - }, - { - value: PlatformRole.OPERATOR, - label: t('Operator'), - description: t( - 'Access and edit flows in all projects, no platform settings', - ), - }, - { - value: PlatformRole.MEMBER, - label: t('Member'), - description: t( - "Access to personal project and any team projects they're invited to", - ), - }, -]; - -const PROJECT_ROLE_DESCRIPTIONS: Record = { - Admin: t('Manage project settings, members, connections, and git sync'), - Editor: t('Build, publish, and manage flows'), - Viewer: t('View flows and monitor run history'), -}; +import { roleCopy } from '@/features/members/lib/role-copy'; +import { platformHooks } from '@/hooks/platform-hooks'; export const getProjectRoleDescription = (roleName: string): string => { - return PROJECT_ROLE_DESCRIPTIONS[roleName] || ''; + return roleCopy.projectRoleDescription(roleName) ?? ''; }; interface RoleSelectorProps { @@ -71,6 +38,7 @@ export const RoleSelector = ({ isLoading = false, isAssigningRole = false, }: RoleSelectorProps) => { + const { platform } = platformHooks.useCurrentPlatform(); const isPlatform = type === 'platform'; const projectRolesLoading = !isPlatform && isLoading; const projectRoleAssigning = !isPlatform && isAssigningRole; @@ -80,11 +48,15 @@ export const RoleSelector = ({ const label = isPlatform ? t('Platform Roles') : t('Project Roles'); const options = isPlatform - ? PLATFORM_ROLES.map((role) => ({ - value: role.value, - label: role.label, - description: role.description, - })) + ? roleCopy + .platformRoles({ + personalProjectsEnabled: platform.autoCreatePersonalProjects, + }) + .map((role) => ({ + value: role.role, + label: role.label, + description: role.description, + })) : roles.map((role) => ({ value: role.name, label: role.name, diff --git a/packages/web/src/features/members/lib/role-copy.ts b/packages/web/src/features/members/lib/role-copy.ts new file mode 100644 index 000000000000..4e68285bc464 --- /dev/null +++ b/packages/web/src/features/members/lib/role-copy.ts @@ -0,0 +1,109 @@ +import { ProjectRole, RoleType } from '@activepieces/core-utils'; +import { DefaultProjectRole, PlatformRole } from '@activepieces/shared'; +import { t } from 'i18next'; + +const BUILT_IN_ORDER: string[] = [ + DefaultProjectRole.ADMIN, + DefaultProjectRole.EDITOR, + DefaultProjectRole.VIEWER, +]; + +function projectRoleDescription(roleName: string): string | null { + switch (roleName) { + case DefaultProjectRole.ADMIN: + return t('Full access'); + case DefaultProjectRole.EDITOR: + return t('Build, publish, and manage flows'); + case DefaultProjectRole.VIEWER: + return t('View only'); + default: + return null; + } +} + +function projectRoleTone(roleName: string): RoleTone { + switch (roleName) { + case DefaultProjectRole.ADMIN: + return 'brand'; + case DefaultProjectRole.EDITOR: + return 'info'; + case DefaultProjectRole.VIEWER: + return 'neutral'; + default: + return 'custom'; + } +} + +function platformRoles({ + personalProjectsEnabled, +}: PlatformRolesParams): PlatformRoleSummary[] { + return [ + { + role: PlatformRole.ADMIN, + tone: 'brand', + label: t('Admin'), + description: t('Full access to all projects and platform settings'), + isDefaultForNewMembers: false, + }, + { + role: PlatformRole.OPERATOR, + tone: 'info', + label: t('Operator'), + description: t( + 'Access and edit flows in all projects, no platform settings', + ), + isDefaultForNewMembers: false, + }, + { + role: PlatformRole.MEMBER, + tone: 'neutral', + label: t('Member'), + description: personalProjectsEnabled + ? t( + "Access to personal project and any team projects they're invited to", + ) + : t("Access to the team projects they're invited to"), + isDefaultForNewMembers: true, + }, + ]; +} + +function sortProjectRoles({ roles }: { roles: ProjectRole[] }): ProjectRole[] { + return [...roles].sort((left, right) => { + const leftRank = builtInRank(left); + const rightRank = builtInRank(right); + if (leftRank !== rightRank) { + return leftRank - rightRank; + } + return left.name.localeCompare(right.name); + }); +} + +function builtInRank(role: ProjectRole): number { + if (role.type !== RoleType.DEFAULT) { + return BUILT_IN_ORDER.length; + } + const index = BUILT_IN_ORDER.indexOf(role.name); + return index === -1 ? BUILT_IN_ORDER.length - 1 : index; +} + +export const roleCopy = { + projectRoleDescription, + projectRoleTone, + platformRoles, + sortProjectRoles, +}; + +export type RoleTone = 'brand' | 'info' | 'neutral' | 'custom'; + +export type PlatformRoleSummary = { + role: PlatformRole; + tone: RoleTone; + label: string; + description: string; + isDefaultForNewMembers: boolean; +}; + +type PlatformRolesParams = { + personalProjectsEnabled: boolean; +}; From 014b7401a789834f802621c405ca629d03894ba4 Mon Sep 17 00:00:00 2001 From: lina-alomari Date: Sun, 27 Sep 2026 13:01:23 +0300 Subject: [PATCH 2/3] feat(web): rebuild the role dialog around view/edit permissions and people (#15698) Co-authored-by: Claude Opus 5 (1M context) --- .../platform-editions-ee/ee-projects-rbac.md | 2 +- .../web/public/locales/en/translation.json | 45 +- .../web/src/app/components/centered-page.tsx | 36 +- .../platform/security/project-role/index.tsx | 1 + .../security/project-role/permission-grid.tsx | 159 ++++ .../project-role/platform-roles-list.tsx | 13 +- .../project-role/project-role-dialog.tsx | 748 ++++++++++-------- .../project-role/project-role-users-table.tsx | 105 --- .../project-role/project-roles-list.tsx | 97 ++- .../security/project-role/role-avatar.tsx | 24 +- .../security/project-role/role-people-tab.tsx | 146 ++++ .../security/project-role/roles-card.tsx | 18 +- .../components/project-role-select.tsx | 13 +- .../members/components/role-selector.tsx | 11 +- .../web/src/features/members/lib/role-copy.ts | 19 + .../features/members/lib/role-permissions.ts | 251 ++++++ .../hooks/project-role-hooks.ts | 32 +- .../members/lib/role-permissions.test.ts | 232 ++++++ 18 files changed, 1416 insertions(+), 536 deletions(-) create mode 100644 packages/web/src/app/routes/platform/security/project-role/permission-grid.tsx delete mode 100644 packages/web/src/app/routes/platform/security/project-role/project-role-users-table.tsx create mode 100644 packages/web/src/app/routes/platform/security/project-role/role-people-tab.tsx create mode 100644 packages/web/src/features/members/lib/role-permissions.ts create mode 100644 packages/web/test/features/members/lib/role-permissions.test.ts diff --git a/brain/knowledge/platform-editions-ee/ee-projects-rbac.md b/brain/knowledge/platform-editions-ee/ee-projects-rbac.md index a693d30db669..ff4d35ffd195 100644 --- a/brain/knowledge/platform-editions-ee/ee-projects-rbac.md +++ b/brain/knowledge/platform-editions-ee/ee-projects-rbac.md @@ -24,7 +24,7 @@ Note it lives under `ee/authentication/`, not `ee/projects/`, which is where mos ### Gotchas - **`rbacMiddleware` only covers HTTP routes. Websocket events bypass it.** A socket is authorized once at connect, against the project it claims in the handshake. After that, every resource id arriving in an event payload is just whatever the client typed, and nothing re-checks it. So a socket handler must look the resource up scoped to the connection's project and check the permission itself. Getting this wrong is how `LOCK_RESOURCE` and `JOIN_PRESENCE` shipped letting any member lock, and read the editors of, another project's flows and tables (SRE-218). `collaborative-resource.ts` has the shape to copy. -- **A new `Permission` needs a row in the role dialog, or custom roles can never grant it.** The toggle list is a hardcoded array, `initialPermissions` in `packages/web/src/app/routes/platform/security/project-role/project-role-dialog.tsx`, and the dialog is a plain `.map()` over it. Default-role grants are hardcoded separately in `access-control-list.ts`, so a permission added there but not here is invisible: ADMIN/EDITOR/VIEWER have it, custom roles cannot be given it, and the feature's tab just never appears for those members. This has already shipped twice — Variables + Knowledge Base (GIT-1751), then Agents. Nothing catches the drift: CI neither typechecks nor unit-tests `web`, so add the row in the same PR as the enum entry. +- **A new `Permission` needs a row in the role grid, or custom roles can never grant it.** The resource-to-permission mapping is a hardcoded array, `groups()` in `packages/web/src/features/members/lib/role-permissions.ts` (it lived inline in `project-role-dialog.tsx` as `initialPermissions` until the Roles & access rebuild), and the grid is a plain `.map()` over it. Default-role grants are hardcoded separately in `access-control-list.ts`, so a permission added there but not here is invisible: ADMIN/EDITOR/VIEWER have it, custom roles cannot be given it, and the feature's tab just never appears for those members. This has already shipped three times — Variables + Knowledge Base (GIT-1751), then Agents, and `PUBLISH_SENSITIVE_FLOW_ACCESS` is missing right now: the grid is **29 of the 30 `Permission` values**, which is why its counter reads "N of 29", and flow-approval rights stay Admin-only. `packages/web/test/features/members/lib/role-permissions.test.ts` now catches the drift — it asserts `PUBLISH_SENSITIVE_FLOW_ACCESS` is the *only* `Permission` absent from the grid, so adding an enum value without a row fails that test. - **The release diff and the release apply have to share one rule, or a field becomes either invisible or permanently dirty.** `flowDiffService` decides whether a flow changed by comparing a `normalize()`d copy of each side, and `projectStateHelper.updateFlowInProject` decides what actually gets written — two places, and for connection auth they disagreed both ways. `normalize()` blanked `input.auth` before comparing, so re-pointing a step at a different connection and changing nothing else was reported `flowsUnchanged` and never applied, while the connection the release created sat unreferenced at status `MISSING`; and when some other edit did make the diff fire, the apply unconditionally restored the destination's old auth on top of the incoming one (`f196530db6`, Jul 2024 secret preservation that predates externalId placeholders). Patching the two sites separately is the trap: it leaves a destination-has-auth / incoming-has-none step reporting changed on **every** apply forever, because the diff and the apply then hold different ideas of what unchanged means. The fix (GIT-1764) is one exported `mergeDestinationAuth` that both call, so the diff compares the merged incoming version — exactly what apply is about to write. Any future field with "keep the destination's value" semantics wants the same shape. Worth knowing while reading this code: `flowStructureUtil.transferFlow` deep-clones before the callback runs, so mutating `step` inside it never touches the caller's state; connection references persist as the literal `{{connections['']}}`, identical in both projects, which is why comparing them across instances is meaningful at all; and `flowVersionService` recomputes `connectionIds` via `extractConnectionIds` on every mutation, so the version tracks the new reference without anyone updating it by hand. - **Piece filtering** now via **piece sets** — `project.pieceSetId` (nullable FK, SET NULL). When `managePiecesEnabled`, new EE projects get the Default set on create; unassigned resolves to Default at filter time. This supersedes the legacy project-plan allow/block list. - **Worker routing**: `workerGroupId` (bare label, `^[a-z0-9_-]+$`) gated by `workerGroupsEnabled`. When set, the project's `EXECUTE_FLOW`/`EXECUTE_WEBHOOK` jobs route to `project-