diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx index 8966401f6b5..e4aae96260a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx @@ -55,7 +55,7 @@ interface MemberRowProps { */ export function MemberRow({ name, email, image, status, roleControl, menu }: MemberRowProps) { return ( -
+
{email} {status} @@ -68,6 +68,8 @@ export function MemberRow({ name, email, image, status, roleControl, menu }: Mem interface MemberSectionProps { /** Section label, e.g. "Teammates (3)" or a workspace name with a count. */ label: string + /** Stable accessible name when `label` includes a mutable count. */ + ariaLabel?: string /** Renders the empty state instead of the row group. */ isEmpty?: boolean /** Copy shown when {@link isEmpty} is true. */ @@ -82,12 +84,13 @@ interface MemberSectionProps { */ export function MemberSection({ label, + ariaLabel, isEmpty = false, emptyText = 'No members yet', children, }: MemberSectionProps) { return ( - + {isEmpty ? ( {emptyText} ) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx index 57c06516b28..a605e8fbd35 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx @@ -2,6 +2,8 @@ import type { ReactNode } from 'react' interface SettingsSectionProps { label: string + /** Stable accessible name when the visible label contains mutable metadata such as a count. */ + ariaLabel?: string /** Optional node rendered immediately to the right of the label (e.g. an info tooltip). */ headerAccessory?: ReactNode /** Optional control pinned to the far right of the header row (e.g. a Select All chip). */ @@ -15,12 +17,13 @@ interface SettingsSectionProps { */ export function SettingsSection({ label, + ariaLabel, headerAccessory, action, children, }: SettingsSectionProps) { return ( -
+
{label} {headerAccessory} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx index 3d81108fbe2..e95dd1e91f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx @@ -425,6 +425,7 @@ export function OrganizationMemberLists({ {showMembersSection && ( @@ -449,6 +450,7 @@ export function OrganizationMemberLists({ { + if (canManage) return + setInviteModalOpen(false) + setTransferDialogOpen(false) + setRemoveMemberDialog((current) => (current.open ? { ...current, open: false } : current)) + }, [canManage]) + const handleOrgNameChange = useCallback((e: React.ChangeEvent) => { const newName = e.target.value setOrgName(newName) @@ -156,7 +193,7 @@ export function TeamManagement({ const handleRemoveMember = useCallback( async (member: Member) => { - if (!session?.user) return + if (!canManage || !session?.user) return if (!member.user?.id) { logger.error('Member object missing user ID', { member }) @@ -176,12 +213,12 @@ export function TeamManagement({ isExternalRemoval: member.role === 'external', }) }, - [session?.user] + [canManage, session?.user] ) const confirmRemoveMember = useCallback(async () => { const { memberId, isSelfRemoval } = removeMemberDialog - if (!session?.user || !memberId) return + if (!canManage || !session?.user || !memberId) return try { await removeMemberMutation.mutateAsync({ @@ -205,6 +242,7 @@ export function TeamManagement({ }, [ removeMemberDialog.memberId, removeMemberDialog.isSelfRemoval, + canManage, session?.user?.id, organizationId, removeMemberMutation, @@ -212,23 +250,26 @@ export function TeamManagement({ const handleTransferDialogOpenChange = useCallback( (next: boolean) => { + if (next && !canManage) return setTransferDialogOpen(next) if (!next) { transferOwnershipMutation.reset() setTransferPortalError(null) } }, - [transferOwnershipMutation] + [canManage, transferOwnershipMutation] ) const handleOpenTransferDialog = useCallback(() => { + if (!canManage) return transferOwnershipMutation.reset() setTransferPortalError(null) setTransferDialogOpen(true) - }, [transferOwnershipMutation]) + }, [canManage, transferOwnershipMutation]) const handleConfirmTransfer = useCallback( async (newOwnerUserId: string) => { + if (!canManage) return try { const result = await transferOwnershipMutation.mutateAsync({ orgId: organizationId, @@ -245,10 +286,11 @@ export function TeamManagement({ logger.error('Failed to transfer ownership', error) } }, - [organizationId, transferOwnershipMutation] + [canManage, organizationId, transferOwnershipMutation] ) const handleOpenTransferBillingPortal = useCallback(() => { + if (!canManage) return setTransferPortalError(null) const portalWindow = window.open('', '_blank') openBillingPortal.mutate( @@ -276,14 +318,22 @@ export function TeamManagement({ }, } ) - }, [organizationId, openBillingPortal]) + }, [canManage, organizationId, openBillingPortal]) const queryError = orgError const errorMessage = queryError instanceof Error ? queryError.message : null const displayOrganization = organization - if (isLoading && !displayOrganization) { - return null + if (isMembersLoading && !displayOrganization) { + return
+ } + + if (hasLoadError && !displayOrganization) { + return ( +
+ Unable to load organization members +
+ ) } if (!displayOrganization) { @@ -308,12 +358,13 @@ export function TeamManagement({ <>
- {adminOrOwner && ( + {canManage && ( )} - + {hasLoadError ? ( + Unable to load organization members + ) : isMembersLoading ? null : ( + + )}
- {adminOrOwner && ( + {canManage && ( { + it('reports loading while any required boundary is unresolved', () => { + expect(resolveTeammatesDataState({ ...readyState, invitationsPlaceholder: true })).toBe( + 'loading' + ) + }) + + it('gives errors priority over loading and placeholder data', () => { + expect( + resolveTeammatesDataState({ + ...readyState, + permissionConfigError: true, + permissionsLoading: true, + workspacesPlaceholder: true, + }) + ).toBe('error') + }) + + it('reports ready only after every required boundary resolves', () => { + expect(resolveTeammatesDataState(readyState)).toBe('ready') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates-state.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates-state.ts new file mode 100644 index 00000000000..6492733a8c7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates-state.ts @@ -0,0 +1,38 @@ +export type TeammatesDataState = 'loading' | 'error' | 'ready' + +export interface TeammatesQueryState { + permissionsError: boolean + invitationsError: boolean + workspacesError: boolean + permissionConfigError: boolean + permissionsLoading: boolean + permissionsPlaceholder: boolean + invitationsLoading: boolean + invitationsPlaceholder: boolean + workspacesLoading: boolean + workspacesPlaceholder: boolean + permissionConfigLoading: boolean +} + +export function resolveTeammatesDataState(state: TeammatesQueryState): TeammatesDataState { + if ( + state.permissionsError || + state.invitationsError || + state.workspacesError || + state.permissionConfigError + ) { + return 'error' + } + if ( + state.permissionsLoading || + state.permissionsPlaceholder || + state.invitationsLoading || + state.invitationsPlaceholder || + state.workspacesLoading || + state.workspacesPlaceholder || + state.permissionConfigLoading + ) { + return 'loading' + } + return 'ready' +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx index 170c5c9ec2d..cc39385e4d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { ChipDropdown, Plus, toast } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { formatDate } from '@sim/utils/formatting' @@ -20,7 +20,9 @@ import { MemberSection, } from '@/app/workspace/[workspaceId]/settings/components/member-list' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { resolveTeammatesDataState } from '@/app/workspace/[workspaceId]/settings/components/teammates/teammates-state' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal' import { @@ -73,27 +75,67 @@ export function Teammates() { const [searchTerm, setSearchTerm] = useSettingsSearch() const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) - const { data: permissions, isPending: permissionsLoading } = - useWorkspacePermissionsQuery(workspaceId) - const { data: invitations } = usePendingInvitations(workspaceId) - const { data: workspaces } = useWorkspacesQuery() + const { + data: permissions, + isPending: permissionsLoading, + isError: permissionsError, + isPlaceholderData: permissionsPlaceholder, + } = useWorkspacePermissionsQuery(workspaceId) + const { + data: invitations, + isPending: invitationsLoading, + isError: invitationsError, + isPlaceholderData: invitationsPlaceholder, + } = usePendingInvitations(workspaceId) + const { + data: workspaces, + isPending: workspacesLoading, + isError: workspacesError, + isPlaceholderData: workspacesPlaceholder, + } = useWorkspacesQuery() const router = useRouter() const queryClient = useQueryClient() - const { isInvitationsDisabled: isInvitationsDisabledByConfig } = usePermissionConfig() + const { + isInvitationsDisabled: isInvitationsDisabledByConfig, + isPermissionLoading: permissionConfigLoading, + isPermissionError: permissionConfigError, + } = usePermissionConfig() const resendInvitation = useResendWorkspaceInvitation() const cancelInvitation = useCancelWorkspaceInvitation() const removeMember = useRemoveWorkspaceMember() const updatePermissions = useUpdateWorkspacePermissions() - const viewer = permissions?.viewer - const canManage = canMutateWorkspaceSettingsSection('teammates', { - canEdit: viewer?.permissionType === 'write' || viewer?.permissionType === 'admin', - canAdmin: Boolean(viewer?.isAdmin), + const activeWorkspace = workspaces?.find((workspace) => workspace.id === workspaceId) + const queryDataState = resolveTeammatesDataState({ + permissionsError, + invitationsError, + workspacesError, + permissionConfigError, + permissionsLoading, + permissionsPlaceholder, + invitationsLoading, + invitationsPlaceholder, + workspacesLoading, + workspacesPlaceholder, + permissionConfigLoading, }) + const dataState = queryDataState === 'ready' && !activeWorkspace ? 'error' : queryDataState + const hasLoadError = dataState === 'error' + const isLoading = dataState === 'loading' + + const viewer = permissions?.viewer + const canManage = + canMutateWorkspaceSettingsSection('teammates', { + canEdit: viewer?.permissionType === 'write' || viewer?.permissionType === 'admin', + canAdmin: Boolean(viewer?.isAdmin), + }) && dataState === 'ready' + + useEffect(() => { + if (!canManage) setIsInviteModalOpen(false) + }, [canManage]) - const activeWorkspace = workspaces?.find((workspace) => workspace.id === workspaceId) const inviteDisabledReason = activeWorkspace?.inviteDisabledReason ?? null const isInvitationsDisabled = isInvitationsDisabledByConfig || inviteDisabledReason !== null @@ -154,151 +196,174 @@ export function Teammates() { ) }, [teammates, searchTerm]) - const showNoResults = !permissionsLoading && filteredTeammates.length === 0 + const showNoResults = dataState === 'ready' && filteredTeammates.length === 0 const handleRoleChange = (teammate: Teammate, role: WorkspacePermission) => { if (!teammate.userId || role === teammate.role) return updatePermissions.mutate({ workspaceId, + organizationId: activeWorkspace?.organizationId ?? undefined, updates: [{ userId: teammate.userId, permissions: role }], }) } return ( <> - - - {filteredTeammates.map((teammate) => ( - { - const lockReason = teammate.isPending - ? null - : workspaceRoleLockReason(teammate.roleSource) - return ( - - handleRoleChange(teammate, role as WorkspacePermission)} - options={ROLE_OPTIONS} - matchTriggerWidth={false} - disabled={ - teammate.isPending || - !canManage || - teammate.userId === viewer?.userId || - lockReason !== null - } - /> - - ) - })()} - menu={ - copyToClipboard(teammate.email), - }, - ...(canManage && teammate.isPending - ? [ - { - label: 'Resend invite', - onSelect: () => { - if (teammate.invitationId) { - resendInvitation.mutate({ - invitationId: teammate.invitationId, - workspaceId, - }) - } - }, - }, - { - label: 'Copy invite link', - onSelect: () => { - if (teammate.invitationId && teammate.token) { - copyToClipboard( - buildInviteLink(teammate.invitationId, teammate.token) - ) - } - }, - }, - { - label: 'Revoke invite', - destructive: true, - onSelect: () => { - if (teammate.invitationId) { - cancelInvitation.mutate({ - invitationId: teammate.invitationId, - workspaceId, - }) - } - }, - }, - ] - : []), - ...(canManage && !teammate.isPending && teammate.userId !== viewer?.userId - ? [ - { - label: 'Remove', - destructive: true, - onSelect: () => { - if (teammate.userId) { - removeMember.mutate( - { userId: teammate.userId, workspaceId }, - { - onError: (error) => { - toast.error("Couldn't remove teammate", { - description: getErrorMessage( - error, - 'Please try again in a moment.' - ), - }) - }, + {hasLoadError ? ( + Unable to load teammates + ) : isLoading ? null : ( + + {filteredTeammates.map((teammate) => ( + { + const lockReason = teammate.isPending + ? null + : workspaceRoleLockReason(teammate.roleSource) + return ( + + + handleRoleChange(teammate, role as WorkspacePermission) + } + options={ROLE_OPTIONS} + matchTriggerWidth={false} + disabled={ + teammate.isPending || + !canManage || + teammate.userId === viewer?.userId || + lockReason !== null + } + /> + + ) + })()} + menu={ + copyToClipboard(teammate.email), + }, + ...(canManage && teammate.isPending + ? [ + { + label: 'Resend invite', + onSelect: () => { + if (teammate.invitationId) { + resendInvitation.mutate({ + invitationId: teammate.invitationId, + workspaceId, + organizationId: activeWorkspace?.organizationId ?? undefined, + }) + } + }, + }, + { + label: 'Copy invite link', + onSelect: () => { + if (teammate.invitationId && teammate.token) { + copyToClipboard( + buildInviteLink(teammate.invitationId, teammate.token) + ) + } + }, + }, + { + label: 'Revoke invite', + destructive: true, + onSelect: () => { + if (teammate.invitationId) { + cancelInvitation.mutate({ + invitationId: teammate.invitationId, + workspaceId, + organizationId: activeWorkspace?.organizationId ?? undefined, + }) } - ) - } - }, - }, - ] - : []), - ]} + }, + }, + ] + : []), + ...(canManage && !teammate.isPending && teammate.userId !== viewer?.userId + ? [ + { + label: 'Remove', + destructive: true, + onSelect: () => { + if (teammate.userId) { + removeMember.mutate( + { + userId: teammate.userId, + workspaceId, + organizationId: + activeWorkspace?.organizationId ?? undefined, + }, + { + onError: (error) => { + toast.error("Couldn't remove teammate", { + description: getErrorMessage( + error, + 'Please try again in a moment.' + ), + }) + }, + } + ) + } + }, + }, + ] + : []), + ]} + /> + } /> - } - /> - ))} - - + ))} + + )} + +
{canManage && ( Promise | void + interface PersonaFixtures { personaManifest: ScenarioManifest contextForPersona: (personaKey: string) => Promise + registerCleanup: (label: string, cleanup: PersonaCleanup) => void } export const test = base.extend({ @@ -55,6 +58,22 @@ export const test = base.extend({ throw new AggregateError(failures, 'Persona browser cleanup or network isolation failed') } }, + registerCleanup: async ({ contextForPersona: _contextForPersona }, use) => { + const cleanups: Array<{ label: string; cleanup: PersonaCleanup }> = [] + await use((label, cleanup) => cleanups.push({ label, cleanup })) + + const failures: unknown[] = [] + for (const { label, cleanup } of cleanups.reverse()) { + try { + await cleanup() + } catch (error) { + failures.push(new Error(`Cleanup failed: ${label}`, { cause: error })) + } + } + if (failures.length > 0) { + throw new AggregateError(failures, 'Persona cleanup registry failed') + } + }, }) export { expect } from '@playwright/test' diff --git a/apps/sim/e2e/foundation/safety.spec.ts b/apps/sim/e2e/foundation/safety.spec.ts index 678029dd04a..f73a4b63a02 100644 --- a/apps/sim/e2e/foundation/safety.spec.ts +++ b/apps/sim/e2e/foundation/safety.spec.ts @@ -93,6 +93,17 @@ test.describe('foundation safety guards', () => { expect(build.env.E2E_RUN_ID).toBe('build_sentinel') expect(build.env.DEPLOY_AS_BLOCK).toBe('true') expect(app.env.DEPLOY_AS_BLOCK).toBe('true') + for (const key of [ + 'RESEND_API_KEY', + 'AWS_SES_REGION', + 'SMTP_HOST', + 'AZURE_ACS_CONNECTION_STRING', + 'GMAIL_SENDER', + 'GMAIL_CREDENTIALS_JSON', + ]) { + expect(build.env[key], `${key} must be absent from the build environment`).toBeUndefined() + expect(app.env[key], `${key} must be absent from the app environment`).toBeUndefined() + } for (const key of [ 'BETTER_AUTH_SECRET', 'ENCRYPTION_KEY', diff --git a/apps/sim/e2e/foundation/scenario-validation.spec.ts b/apps/sim/e2e/foundation/scenario-validation.spec.ts index edfef9a35f9..dc008140606 100644 --- a/apps/sim/e2e/foundation/scenario-validation.spec.ts +++ b/apps/sim/e2e/foundation/scenario-validation.spec.ts @@ -65,6 +65,22 @@ test.describe('pure scenario validation', () => { ({ userKey }) => userKey === 'enterprise-organization-admin' ) ).toBe(false) + expect(workspaceExpectation(primary, 'teamWorkflowMember')).toMatchObject({ + access: 'read', + roleSource: 'explicit', + hostContext: { hostMembership: 'member', plan: 'team_6000' }, + }) + expect(workspaceExpectation(primary, 'enterpriseWorkflowMember')).toMatchObject({ + access: 'read', + roleSource: 'explicit', + hostContext: { hostMembership: 'member', plan: 'enterprise' }, + }) + expect(primary.personasByKey.get('enterpriseWorkflowMember')?.permissionGroups).toEqual([]) + expect(primary.subscriptionsByKey.get('team-subscription')).toMatchObject({ seats: 5 }) + expect(primary.subscriptionsByKey.get('enterprise-subscription')).toMatchObject({ + seats: 4, + enterprise: { seats: 4 }, + }) expect(workspaceExpectation(primary, 'freeOrganizationOwner').hostContext).toMatchObject({ payerScope: 'user', plan: 'free', diff --git a/apps/sim/e2e/scripts/seed-world.ts b/apps/sim/e2e/scripts/seed-world.ts index 34c195982e2..79c969ca4c2 100644 --- a/apps/sim/e2e/scripts/seed-world.ts +++ b/apps/sim/e2e/scripts/seed-world.ts @@ -4,6 +4,9 @@ import { invitation, invitationWorkspaceGrant, member, + permissionGroup, + permissionGroupMember, + permissionGroupWorkspace, permissions, subscription, user, @@ -12,6 +15,7 @@ import { } from '@sim/db/schema' import { and, eq, inArray } from 'drizzle-orm' import { z } from 'zod' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' import { buildScenarioManifest, createWorldRecords, @@ -637,6 +641,157 @@ async function assertTrustedWorldInvariants(world: E2EWorld): Promise { throw new Error(`Persisted invitation grants do not match scenario: ${definition.key}`) } } + if (world.scenario.definition.namespace.world === 'settings-primary') { + await assertWorkflowPersonaInvariants(world) + } +} + +async function assertWorkflowPersonaInvariants(world: E2EWorld): Promise { + const teamTarget = required(world.records.users, 'team-workflow-member', 'Team workflow member') + const enterpriseTarget = required( + world.records.users, + 'enterprise-workflow-member', + 'Enterprise workflow member' + ) + + const teamOrganization = required( + world.records.organizations, + 'team-organization', + 'workflow organization' + ) + const enterpriseOrganization = required( + world.records.organizations, + 'enterprise-organization', + 'workflow organization' + ) + const teamWorkspace = required(world.records.workspaces, 'team-workspace', 'workflow workspace') + const teamInvitationWorkspace = required( + world.records.workspaces, + 'team-invitation-workspace', + 'workflow workspace' + ) + const enterpriseWorkspace = required( + world.records.workspaces, + 'enterprise-workspace', + 'workflow workspace' + ) + + const teamMembers = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, teamOrganization.id)) + const teamPending = await db + .select({ id: invitation.id }) + .from(invitation) + .where( + and(eq(invitation.organizationId, teamOrganization.id), eq(invitation.status, 'pending')) + ) + const teamSubscription = world.scenario.subscriptionsByKey.get('team-subscription') + if ( + teamMembers.length !== 5 || + teamPending.length !== 1 || + teamSubscription?.seats !== 5 || + teamMembers.length + teamPending.length !== 6 + ) { + throw new Error('Team workflow seat baseline does not match the repeatable scenario') + } + + const teamTargetPermissions = await db + .select({ workspaceId: permissions.entityId, permission: permissions.permissionType }) + .from(permissions) + .where( + and( + eq(permissions.userId, teamTarget.id), + eq(permissions.entityType, 'workspace'), + inArray(permissions.entityId, [teamWorkspace.id, teamInvitationWorkspace.id]) + ) + ) + if ( + teamTargetPermissions.length !== 1 || + teamTargetPermissions[0].workspaceId !== teamWorkspace.id || + teamTargetPermissions[0].permission !== 'read' + ) { + throw new Error('Team workflow member must retain only its explicit Read anchor') + } + + const enterpriseMembers = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, enterpriseOrganization.id)) + const enterpriseSubscription = world.scenario.subscriptionsByKey.get('enterprise-subscription') + const enterpriseTargetPermissions = await db + .select({ workspaceId: permissions.entityId, permission: permissions.permissionType }) + .from(permissions) + .where( + and( + eq(permissions.userId, enterpriseTarget.id), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, enterpriseWorkspace.id) + ) + ) + if ( + enterpriseMembers.length !== 4 || + enterpriseSubscription?.seats !== 4 || + enterpriseSubscription.enterprise?.seats !== 4 || + enterpriseTargetPermissions.length !== 1 || + enterpriseTargetPermissions[0].permission !== 'read' + ) { + throw new Error('Enterprise workflow member baseline does not match the repeatable scenario') + } + + const restrictedGroupId = required( + world.records.permissionGroups, + 'restricted-enterprise-group', + 'restricted permission group' + ) + const [restrictedGroup] = await db + .select({ + organizationId: permissionGroup.organizationId, + config: permissionGroup.config, + isDefault: permissionGroup.isDefault, + }) + .from(permissionGroup) + .where(eq(permissionGroup.id, restrictedGroupId)) + .limit(1) + const restrictedMembers = await db + .select({ userId: permissionGroupMember.userId }) + .from(permissionGroupMember) + .where(eq(permissionGroupMember.permissionGroupId, restrictedGroupId)) + const restrictedWorkspaces = await db + .select({ workspaceId: permissionGroupWorkspace.workspaceId }) + .from(permissionGroupWorkspace) + .where(eq(permissionGroupWorkspace.permissionGroupId, restrictedGroupId)) + const expectedRestrictedConfig = { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideSecretsTab: true, + hideApiKeysTab: true, + hideInboxTab: true, + disableMcpTools: true, + disableCustomTools: true, + } + if ( + restrictedGroup?.organizationId !== enterpriseOrganization.id || + restrictedGroup.isDefault || + !sameJsonRecord(restrictedGroup.config, expectedRestrictedConfig) || + restrictedMembers.length !== 1 || + restrictedMembers[0].userId !== + required(world.records.users, 'permission-group-restricted', 'restricted group member').id || + restrictedWorkspaces.length !== 1 || + restrictedWorkspaces[0].workspaceId !== enterpriseWorkspace.id + ) { + throw new Error('Seeded restricted Enterprise permission group changed unexpectedly') + } +} + +function sameJsonRecord(actual: unknown, expected: Record): boolean { + if (!actual || typeof actual !== 'object' || Array.isArray(actual)) return false + const record = actual as Record + const actualKeys = Object.keys(record).sort() + const expectedKeys = Object.keys(expected).sort() + return ( + JSON.stringify(actualKeys) === JSON.stringify(expectedKeys) && + expectedKeys.every((key) => JSON.stringify(record[key]) === JSON.stringify(expected[key])) + ) } async function assertSecondOrganizationIsRejected( diff --git a/apps/sim/e2e/settings/personas.ts b/apps/sim/e2e/settings/personas.ts index 7dd7dc62220..d73859ae6c1 100644 --- a/apps/sim/e2e/settings/personas.ts +++ b/apps/sim/e2e/settings/personas.ts @@ -21,7 +21,9 @@ export const SETTINGS_PERSONA_KEYS = [ 'workspaceWriteMember', 'workspaceAdminMember', 'externalWorkspaceAdmin', + 'teamWorkflowMember', 'enterpriseOrganizationAdmin', + 'enterpriseWorkflowMember', 'freeOrganizationOwner', 'permissionGroupRestricted', 'platformAdmin', @@ -53,8 +55,10 @@ export function createPrimarySettingsScenario(namespace: ScenarioNamespace): Sce 'workspace-write-member', 'workspace-admin-member', 'external-workspace-admin', + 'team-workflow-member', 'enterprise-organization-owner', 'enterprise-organization-admin', + 'enterprise-workflow-member', 'free-organization-owner', 'permission-group-restricted', 'platform-admin', @@ -94,8 +98,10 @@ export function createPrimarySettingsScenario(namespace: ScenarioNamespace): Sce membership('team-organization', 'workspace-read-member', 'member'), membership('team-organization', 'workspace-write-member', 'member'), membership('team-organization', 'workspace-admin-member', 'member'), + membership('team-organization', 'team-workflow-member', 'member'), membership('enterprise-organization', 'enterprise-organization-owner', 'owner'), membership('enterprise-organization', 'enterprise-organization-admin', 'admin'), + membership('enterprise-organization', 'enterprise-workflow-member', 'member'), membership('enterprise-organization', 'permission-group-restricted', 'member'), membership('lapsed-organization', 'free-organization-owner', 'owner'), ] as const @@ -120,7 +126,7 @@ export function createPrimarySettingsScenario(namespace: ScenarioNamespace): Sce plan: 'team_6000', status: 'active', billingReference: { kind: 'organization', organizationKey: 'team-organization' }, - seats: 4, + seats: 5, ...HOSTED_BILLING, }, { @@ -131,11 +137,11 @@ export function createPrimarySettingsScenario(namespace: ScenarioNamespace): Sce kind: 'organization', organizationKey: 'enterprise-organization', }, - seats: 3, + seats: 4, enterprise: { plan: 'enterprise', monthlyPrice: 12_000, - seats: 3, + seats: 4, }, ...HOSTED_BILLING, }, @@ -207,7 +213,9 @@ export function createPrimarySettingsScenario(namespace: ScenarioNamespace): Sce grant('team-workspace', 'workspace-write-member', 'write'), grant('team-workspace', 'workspace-admin-member', 'admin'), grant('team-workspace', 'external-workspace-admin', 'admin'), + grant('team-workspace', 'team-workflow-member', 'read'), grant('enterprise-workspace', 'permission-group-restricted', 'read'), + grant('enterprise-workspace', 'enterprise-workflow-member', 'read'), ] as const const permissionGroups = [ @@ -282,6 +290,9 @@ export function createPrimarySettingsScenario(namespace: ScenarioNamespace): Sce false ), ]), + persona(namespace, 'teamWorkflowMember', 'team-workflow-member', [ + expected('team-workspace', 'read', 'explicit', 'member', 'organization', 'team_6000', false), + ]), persona(namespace, 'enterpriseOrganizationAdmin', 'enterprise-organization-admin', [ expected( 'enterprise-workspace', @@ -293,6 +304,17 @@ export function createPrimarySettingsScenario(namespace: ScenarioNamespace): Sce false ), ]), + persona(namespace, 'enterpriseWorkflowMember', 'enterprise-workflow-member', [ + expected( + 'enterprise-workspace', + 'read', + 'explicit', + 'member', + 'organization', + 'enterprise', + false + ), + ]), persona(namespace, 'freeOrganizationOwner', 'free-organization-owner', [ expected('lapsed-organization-workspace', 'admin', 'owner', 'owner', 'user', 'free', true), ]), diff --git a/apps/sim/e2e/settings/workflows/access-control.spec.ts b/apps/sim/e2e/settings/workflows/access-control.spec.ts new file mode 100644 index 00000000000..1a77fccac31 --- /dev/null +++ b/apps/sim/e2e/settings/workflows/access-control.spec.ts @@ -0,0 +1,150 @@ +import { + deletePermissionGroupByName, + expectAccessControlReady, + expectRestrictedWorkspace, + expectUnrestrictedWorkspace, + listPermissionGroups, + newPersonaPage, + primaryWorldIds, + uniqueWorkflowName, + waitForSameOriginResponse, +} from './helpers' +import { expect, test } from './workflow-test' + +test('dynamic permission group denies five settings surfaces and deletion restores them', async ({ + contextForPersona, + personaManifest, + registerCleanup, +}) => { + const ids = primaryWorldIds(personaManifest) + const target = personaManifest.personas.enterpriseWorkflowMember + const workspaceName = + personaManifest.worlds['settings-primary'].workspaceIdentities['enterprise-workspace'].name + const seededGroupId = + personaManifest.worlds['settings-primary'].permissionGroupIds['restricted-enterprise-group'] + const groupName = uniqueWorkflowName('dynamic-restrictions') + const { context: adminContext, page } = await newPersonaPage( + contextForPersona, + 'enterpriseOrganizationAdmin' + ) + const { context: baselineTargetContext } = await newPersonaPage( + contextForPersona, + 'enterpriseWorkflowMember' + ) + + registerCleanup('remove dynamic permission group', () => + deletePermissionGroupByName(adminContext.request, ids.enterpriseOrganizationId, groupName) + ) + await deletePermissionGroupByName(adminContext.request, ids.enterpriseOrganizationId, groupName) + + const seededGroupBefore = ( + await listPermissionGroups(adminContext.request, ids.enterpriseOrganizationId) + ).find(({ id }) => id === seededGroupId) + if (!seededGroupBefore) throw new Error('Missing seeded restricted Enterprise group') + + await expectUnrestrictedWorkspace(baselineTargetContext, ids.enterpriseWorkspaceId, 'read') + + await page.goto( + `/workspace/${encodeURIComponent(ids.enterpriseWorkspaceId)}/settings/access-control` + ) + let accessControl = await expectAccessControlReady(page) + await page.getByRole('button', { name: 'Create group' }).click() + const createModal = page.getByRole('dialog', { name: 'Create Permission Group' }) + await createModal.getByRole('textbox', { name: 'Name' }).fill(groupName) + await createModal.getByRole('textbox', { name: 'Description (optional)' }).fill('Step 6 workflow') + await createModal.getByRole('button', { name: 'Select workspaces…' }).click() + await page.getByRole('menuitem', { name: workspaceName, exact: true }).click() + await page.keyboard.press('Escape') + + const createResponse = waitForSameOriginResponse( + page, + 'POST', + `/api/organizations/${ids.enterpriseOrganizationId}/permission-groups` + ) + await createModal.getByRole('button', { name: 'Create', exact: true }).click() + expect((await createResponse).status()).toBe(201) + + const created = ( + await listPermissionGroups(adminContext.request, ids.enterpriseOrganizationId) + ).find(({ name }) => name === groupName) + expect(created).toMatchObject({ + name: groupName, + memberCount: 0, + isDefault: false, + workspaces: [expect.objectContaining({ id: ids.enterpriseWorkspaceId })], + }) + if (!created) throw new Error('Unable to recover created permission group') + + await accessControl.getByRole('button', { name: `Open permission group ${groupName}` }).click() + accessControl = await expectAccessControlReady(page) + const membersSection = accessControl.getByRole('region', { name: 'Members', exact: true }) + await membersSection.getByRole('button', { name: 'Add', exact: true }).click() + const addMembersModal = page.getByRole('dialog', { name: 'Add Members' }) + await addMembersModal.getByRole('textbox', { name: 'Search members...' }).fill(target.email) + await addMembersModal.getByRole('button', { name: new RegExp(target.email, 'i') }).click() + + const addMemberResponse = waitForSameOriginResponse( + page, + 'POST', + `/api/organizations/${ids.enterpriseOrganizationId}/permission-groups/${created.id}/members/bulk` + ) + await addMembersModal.getByRole('button', { name: 'Add Members' }).click() + expect((await addMemberResponse).status()).toBe(200) + await expect(membersSection.getByRole('group', { name: target.email })).toBeVisible() + + await accessControl.getByRole('radio', { name: 'Platform' }).click() + for (const label of ['Secrets', 'API Keys', 'Sim Mailer', 'MCP Tools', 'Custom Tools']) { + const checkbox = accessControl.getByRole('checkbox', { name: label, exact: true }) + await expect(checkbox).toBeChecked() + await checkbox.click() + await expect(checkbox).not.toBeChecked() + } + + const saveResponse = waitForSameOriginResponse( + page, + 'PUT', + `/api/organizations/${ids.enterpriseOrganizationId}/permission-groups/${created.id}` + ) + await page.getByRole('button', { name: 'Save', exact: true }).click() + expect((await saveResponse).status()).toBe(200) + + const persisted = ( + await listPermissionGroups(adminContext.request, ids.enterpriseOrganizationId) + ).find(({ id }) => id === created.id) + expect(persisted?.memberCount).toBe(1) + expect(persisted?.config).toMatchObject({ + hideSecretsTab: true, + hideApiKeysTab: true, + hideInboxTab: true, + disableMcpTools: true, + disableCustomTools: true, + }) + expect( + (await listPermissionGroups(adminContext.request, ids.enterpriseOrganizationId)).find( + ({ id }) => id === seededGroupId + ) + ).toEqual(seededGroupBefore) + + await expectUnrestrictedWorkspace(adminContext, ids.enterpriseWorkspaceId, 'organization-admin') + const restrictedTargetContext = await contextForPersona('enterpriseWorkflowMember') + await expectRestrictedWorkspace(restrictedTargetContext, ids.enterpriseWorkspaceId, created.id) + + await page.bringToFront() + const deleteResponse = waitForSameOriginResponse( + page, + 'DELETE', + `/api/organizations/${ids.enterpriseOrganizationId}/permission-groups/${created.id}` + ) + await page.getByRole('button', { name: 'Delete', exact: true }).click() + const confirmation = page.getByRole('dialog', { name: 'Delete Permission Group' }) + await confirmation.getByRole('button', { name: 'Delete', exact: true }).click() + expect((await deleteResponse).status()).toBe(200) + expect( + (await listPermissionGroups(adminContext.request, ids.enterpriseOrganizationId)).some( + ({ id }) => id === created.id + ) + ).toBe(false) + + const restoredTargetContext = await contextForPersona('enterpriseWorkflowMember') + await expectUnrestrictedWorkspace(restoredTargetContext, ids.enterpriseWorkspaceId, 'read') +}) diff --git a/apps/sim/e2e/settings/workflows/contract-integrity.spec.ts b/apps/sim/e2e/settings/workflows/contract-integrity.spec.ts new file mode 100644 index 00000000000..5c438b0641c --- /dev/null +++ b/apps/sim/e2e/settings/workflows/contract-integrity.spec.ts @@ -0,0 +1,20 @@ +import { accessGateCases } from '../authorization/contracts' +import { SETTINGS_PERSONA_KEYS } from '../personas' +import { dynamicRestrictionCases, peopleWorkflowCases, workflowPersonaKeys } from './contracts' +import { expect, test } from './workflow-test' + +test('workflow contracts reference durable personas and authorization proofs', () => { + const personaKeys = new Set(SETTINGS_PERSONA_KEYS) + const accessCaseIds = new Set(accessGateCases.map(({ caseId }) => caseId)) + + expect(new Set(peopleWorkflowCases.map(({ caseId }) => caseId)).size).toBe( + peopleWorkflowCases.length + ) + expect(new Set(dynamicRestrictionCases.map(({ sectionId }) => sectionId)).size).toBe( + dynamicRestrictionCases.length + ) + for (const personaKey of workflowPersonaKeys) expect(personaKeys).toContain(personaKey) + for (const restriction of dynamicRestrictionCases) { + expect(accessCaseIds).toContain(restriction.existingProofId) + } +}) diff --git a/apps/sim/e2e/settings/workflows/contracts.ts b/apps/sim/e2e/settings/workflows/contracts.ts new file mode 100644 index 00000000000..717d93df4e6 --- /dev/null +++ b/apps/sim/e2e/settings/workflows/contracts.ts @@ -0,0 +1,52 @@ +export const peopleWorkflowCases = [ + { + caseId: 'workspace-new-invitation-lifecycle', + actor: 'paidOrganizationOwner', + workspaceKey: 'team-invitation-workspace', + }, + { + caseId: 'organization-new-invitation-lifecycle', + actor: 'paidOrganizationOwner', + workspaceKey: 'team-workspace', + }, + { + caseId: 'organization-real-member-lifecycle', + actor: 'paidOrganizationOwner', + subject: 'teamWorkflowMember', + }, +] as const + +export const dynamicRestrictionCases = [ + { + sectionId: 'secrets', + label: 'Secrets', + flag: 'hideSecretsTab', + existingProofId: 'workspace-permission-group-secrets-denied', + }, + { + sectionId: 'apikeys', + label: 'Sim API keys', + flag: 'hideApiKeysTab', + existingProofId: 'workspace-permission-group-apikeys-denied', + }, + { + sectionId: 'inbox', + label: 'Sim mailer', + flag: 'hideInboxTab', + existingProofId: 'workspace-permission-group-inbox-denied', + }, + { + sectionId: 'mcp', + label: 'MCP tools', + flag: 'disableMcpTools', + existingProofId: 'workspace-permission-group-mcp-denied', + }, + { + sectionId: 'custom-tools', + label: 'Custom tools', + flag: 'disableCustomTools', + existingProofId: 'workspace-permission-group-custom-tools-denied', + }, +] as const + +export const workflowPersonaKeys = ['teamWorkflowMember', 'enterpriseWorkflowMember'] as const diff --git a/apps/sim/e2e/settings/workflows/helpers.ts b/apps/sim/e2e/settings/workflows/helpers.ts new file mode 100644 index 00000000000..175c3099260 --- /dev/null +++ b/apps/sim/e2e/settings/workflows/helpers.ts @@ -0,0 +1,525 @@ +import { randomUUID } from 'node:crypto' +import type { APIRequestContext, BrowserContext, Locator, Page, Response } from '@playwright/test' +import { z } from 'zod' +import { + type OrganizationRoster, + organizationRosterSchema, + type RosterMember, + type RosterPendingInvitation, +} from '@/lib/api/contracts/organization' +import type { ScenarioManifest } from '../../fixtures/e2e-world' +import { absoluteE2eUrl } from '../navigation/contract-resolver' +import { dynamicRestrictionCases } from './contracts' +import { expect } from './workflow-test' + +const rosterEnvelopeSchema = z.object({ + success: z.boolean(), + data: organizationRosterSchema, +}) + +const permissionGroupListSchema = z.object({ + permissionGroups: z.array( + z.object({ + id: z.string(), + name: z.string(), + memberCount: z.number(), + isDefault: z.boolean(), + workspaces: z.array(z.object({ id: z.string(), name: z.string() })), + config: z.record(z.string(), z.unknown()), + }) + ), +}) + +export interface PrimaryWorldIds { + teamOrganizationId: string + enterpriseOrganizationId: string + teamWorkspaceId: string + teamInvitationWorkspaceId: string + enterpriseWorkspaceId: string +} + +export function primaryWorldIds(manifest: ScenarioManifest): PrimaryWorldIds { + const world = manifest.worlds['settings-primary'] + if (!world) throw new Error('Missing settings-primary world') + return { + teamOrganizationId: required(world.organizationIds, 'team-organization', 'organization'), + enterpriseOrganizationId: required( + world.organizationIds, + 'enterprise-organization', + 'organization' + ), + teamWorkspaceId: required(world.workspaceIds, 'team-workspace', 'workspace'), + teamInvitationWorkspaceId: required( + world.workspaceIds, + 'team-invitation-workspace', + 'workspace' + ), + enterpriseWorkspaceId: required(world.workspaceIds, 'enterprise-workspace', 'workspace'), + } +} + +export function uniqueWorkflowEmail(label: string): string { + return `e2e-${label}-${randomUUID()}@example.com` +} + +export function uniqueWorkflowName(label: string): string { + return `e2e-${label}-${randomUUID()}` +} + +export async function newPersonaPage( + contextForPersona: (personaKey: string) => Promise, + personaKey: string +): Promise<{ context: BrowserContext; page: Page }> { + const context = await contextForPersona(personaKey) + const page = await context.newPage() + const response = await page.goto(absoluteE2eUrl('/account/settings/general')) + if (!response?.ok()) throw new Error(`Unable to initialize workflow origin for ${personaKey}`) + return { context, page } +} + +export async function expectTeammatesReady(page: Page): Promise { + const region = page.getByRole('region', { name: 'Workspace teammates' }) + await expect(region).toHaveAttribute('aria-busy', 'false') + await expect(region).toHaveAttribute('data-teammates-state', 'ready') + return region +} + +export async function expectOrganizationMembersReady(page: Page): Promise { + const region = page.getByRole('region', { name: 'Organization members' }) + await expect(region).toHaveAttribute('aria-busy', 'false') + await expect(region).toHaveAttribute('data-members-state', 'ready') + return region +} + +export async function expectAccessControlReady(page: Page): Promise { + const region = page.getByRole('region', { name: 'Access control' }) + await expect(region).toHaveAttribute('aria-busy', 'false') + await expect(region).toHaveAttribute('data-access-control-state', 'ready') + return region +} + +export function waitForSameOriginResponse( + page: Page, + method: string, + pathname: string +): Promise { + const origin = new URL(absoluteE2eUrl('/')).origin + return page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + url.origin === origin && url.pathname === pathname && response.request().method() === method + ) + }) +} + +export async function enterEmail(modal: Locator, email: string): Promise { + const input = modal.getByRole('textbox', { name: 'Emails' }) + await input.fill(email) + await input.press('Enter') + await expect(modal.getByText(email, { exact: true })).toBeVisible() +} + +export async function selectDropdownOption( + scope: Locator, + currentLabel: string, + nextLabel: string +): Promise { + await scope.getByRole('button', { name: currentLabel, exact: true }).click() + await scope.page().getByRole('menuitem', { name: nextLabel, exact: true }).click() +} + +export async function getOrganizationRoster( + request: APIRequestContext, + organizationId: string +): Promise { + const response = await request.get( + `/api/organizations/${encodeURIComponent(organizationId)}/roster` + ) + expect(response.status()).toBe(200) + return rosterEnvelopeSchema.parse(await response.json()).data +} + +export function findRosterInvitation( + roster: OrganizationRoster, + email: string +): RosterPendingInvitation | undefined { + return roster.pendingInvitations.find( + (invitation) => invitation.email.toLowerCase() === email.toLowerCase() + ) +} + +export function findRosterMember( + roster: OrganizationRoster, + email: string +): RosterMember | undefined { + return roster.members.find((member) => member.email.toLowerCase() === email.toLowerCase()) +} + +export async function deleteInvitationByEmail( + request: APIRequestContext, + organizationId: string, + email: string +): Promise { + let roster = await getOrganizationRoster(request, organizationId) + for (const invitation of roster.pendingInvitations.filter( + (candidate) => candidate.email.toLowerCase() === email.toLowerCase() + )) { + const response = await request.delete(`/api/invitations/${encodeURIComponent(invitation.id)}`) + if (response.status() !== 200 && response.status() !== 404) { + throw new Error(`Invitation cleanup failed with ${response.status()}`) + } + } + roster = await getOrganizationRoster(request, organizationId) + expect(findRosterInvitation(roster, email)).toBeUndefined() +} + +export async function listPermissionGroups( + request: APIRequestContext, + organizationId: string +): Promise['permissionGroups']> { + const response = await request.get( + `/api/organizations/${encodeURIComponent(organizationId)}/permission-groups` + ) + expect(response.status()).toBe(200) + return permissionGroupListSchema.parse(await response.json()).permissionGroups +} + +export async function deletePermissionGroupByName( + request: APIRequestContext, + organizationId: string, + name: string +): Promise { + const matches = (await listPermissionGroups(request, organizationId)).filter( + (group) => group.name === name + ) + for (const group of matches) { + const response = await request.delete( + `/api/organizations/${encodeURIComponent(organizationId)}/permission-groups/${encodeURIComponent(group.id)}` + ) + if (response.status() !== 200 && response.status() !== 404) { + throw new Error(`Permission-group cleanup failed with ${response.status()}`) + } + } + expect( + (await listPermissionGroups(request, organizationId)).some((group) => group.name === name) + ).toBe(false) +} + +export async function restoreTeamWorkflowMember(options: { + adminRequest: APIRequestContext + targetRequest: APIRequestContext + organizationId: string + anchorWorkspaceId: string + invitationWorkspaceId: string + targetUserId: string + targetEmail: string +}): Promise { + const { + adminRequest, + targetRequest, + organizationId, + anchorWorkspaceId, + invitationWorkspaceId, + targetUserId, + targetEmail, + } = options + + await deleteInvitationByEmail(adminRequest, organizationId, targetEmail) + let roster = await getOrganizationRoster(adminRequest, organizationId) + let target = findRosterMember(roster, targetEmail) + + if (!target) { + const invite = await adminRequest.post( + `/api/organizations/${encodeURIComponent(organizationId)}/invitations`, + { data: { email: targetEmail, role: 'member' } } + ) + expect(invite.status()).toBe(200) + roster = await getOrganizationRoster(adminRequest, organizationId) + const invitation = findRosterInvitation(roster, targetEmail) + if (!invitation) throw new Error('Unable to recover restoration invitation') + const accepted = await targetRequest.post( + `/api/invitations/${encodeURIComponent(invitation.id)}/accept`, + { data: {} } + ) + expect(accepted.status()).toBe(200) + roster = await getOrganizationRoster(adminRequest, organizationId) + target = findRosterMember(roster, targetEmail) + } + if (!target) throw new Error('Unable to restore workflow organization member') + + if (target.role !== 'member') { + const demoted = await adminRequest.put( + `/api/organizations/${encodeURIComponent(organizationId)}/members/${encodeURIComponent( + targetUserId + )}`, + { data: { role: 'member' } } + ) + expect(demoted.status()).toBe(200) + } + + roster = await getOrganizationRoster(adminRequest, organizationId) + target = findRosterMember(roster, targetEmail) + if (target?.workspaces.some(({ workspaceId }) => workspaceId === invitationWorkspaceId)) { + const removed = await adminRequest.delete( + `/api/workspaces/members/${encodeURIComponent(targetUserId)}`, + { data: { workspaceId: invitationWorkspaceId } } + ) + expect(removed.status()).toBe(200) + } + + roster = await getOrganizationRoster(adminRequest, organizationId) + target = findRosterMember(roster, targetEmail) + const anchor = target?.workspaces.find(({ workspaceId }) => workspaceId === anchorWorkspaceId) + if (!anchor) { + const granted = await adminRequest.post('/api/workspaces/invitations/batch', { + data: { + workspaceId: anchorWorkspaceId, + invitations: [{ email: targetEmail, permission: 'read' }], + }, + }) + expect(granted.status()).toBe(200) + } else if (anchor.permission !== 'read') { + const normalized = await adminRequest.patch( + `/api/workspaces/${encodeURIComponent(anchorWorkspaceId)}/permissions`, + { data: { updates: [{ userId: targetUserId, permissions: 'read' }] } } + ) + expect(normalized.status()).toBe(200) + } + + await setActiveOrganization(targetRequest, organizationId) + await expectTeamWorkflowMemberBaseline({ + adminRequest, + targetRequest, + organizationId, + anchorWorkspaceId, + invitationWorkspaceId, + targetEmail, + }) +} + +export async function expectTeamWorkflowMemberBaseline(options: { + adminRequest: APIRequestContext + targetRequest: APIRequestContext + organizationId: string + anchorWorkspaceId: string + invitationWorkspaceId: string + targetEmail: string +}): Promise { + const { + adminRequest, + targetRequest, + organizationId, + anchorWorkspaceId, + invitationWorkspaceId, + targetEmail, + } = options + const roster = await getOrganizationRoster(adminRequest, organizationId) + const target = findRosterMember(roster, targetEmail) + expect(target?.role).toBe('member') + expect(target?.workspaces).toEqual([ + expect.objectContaining({ workspaceId: anchorWorkspaceId, permission: 'read' }), + ]) + expect(target?.workspaces.some(({ workspaceId }) => workspaceId === invitationWorkspaceId)).toBe( + false + ) + expect(findRosterInvitation(roster, targetEmail)).toBeUndefined() + expect(roster.members.filter(({ role }) => role !== 'external')).toHaveLength(5) + expect(roster.pendingInvitations).toHaveLength(1) + expect(roster.pendingInvitations[0]).toMatchObject({ + kind: 'organization', + membershipIntent: 'internal', + }) + + const billingResponse = await adminRequest.get( + `/api/billing?context=organization&id=${encodeURIComponent(organizationId)}` + ) + expect(billingResponse.status()).toBe(200) + const billing = (await billingResponse.json()) as { + data?: { totalSeats?: number; usedSeats?: number; members?: unknown[] } + } + expect(billing.data).toMatchObject({ totalSeats: 5, usedSeats: 6 }) + expect(billing.data?.members).toHaveLength(5) + + const sessionResponse = await targetRequest.get('/api/auth/get-session?disableCookieCache=true') + expect(sessionResponse.status()).toBe(200) + const session = (await sessionResponse.json()) as { + session?: { activeOrganizationId?: string | null } + } + expect(session.session?.activeOrganizationId ?? null).toBe(organizationId) +} + +export async function setActiveOrganization( + request: APIRequestContext, + organizationId: string +): Promise { + const currentSession = await request.get('/api/auth/get-session?disableCookieCache=true') + expect(currentSession.status()).toBe(200) + const current = (await currentSession.json()) as { + session?: { activeOrganizationId?: string | null } + } + if ((current.session?.activeOrganizationId ?? null) === organizationId) return + + const response = await request.post('/api/auth/organization/set-active', { + data: { organizationId }, + headers: { origin: new URL(absoluteE2eUrl('/')).origin }, + }) + expect(response.status()).toBe(200) + const session = await request.get('/api/auth/get-session?disableCookieCache=true') + expect(session.status()).toBe(200) +} + +export async function expectRestrictedWorkspace( + context: BrowserContext, + workspaceId: string, + expectedGroupId: string +): Promise { + const configResponse = await context.request.get( + `/api/permission-groups/user?workspaceId=${encodeURIComponent(workspaceId)}` + ) + expect(configResponse.status()).toBe(200) + const config = (await configResponse.json()) as { + permissionGroupId?: string | null + config?: Record | null + } + expect(config.permissionGroupId).toBe(expectedGroupId) + for (const restriction of dynamicRestrictionCases) { + expect(config.config?.[restriction.flag]).toBe(true) + } + + const page = await context.newPage() + await page.goto(absoluteE2eUrl(`/workspace/${encodeURIComponent(workspaceId)}/settings/general`)) + const navigation = page + .getByRole('complementary', { name: 'Workspace sidebar' }) + .getByRole('navigation', { name: 'Workspace settings sections' }) + await expect(navigation).toHaveAttribute('aria-busy', 'false') + for (const restriction of dynamicRestrictionCases) { + await expect( + navigation.getByRole('button', { name: restriction.label, exact: true }) + ).toHaveCount(0) + const response = await page.goto( + absoluteE2eUrl( + `/workspace/${encodeURIComponent(workspaceId)}/settings/${restriction.sectionId}` + ) + ) + expect(response?.status()).toBe(404) + await expect(page.getByRole('heading', { name: 'Page not found' })).toBeVisible() + } +} + +export async function expectUnrestrictedWorkspace( + context: BrowserContext, + workspaceId: string, + authority: 'read' | 'organization-admin' +): Promise { + const configResponse = await context.request.get( + `/api/permission-groups/user?workspaceId=${encodeURIComponent(workspaceId)}` + ) + expect(configResponse.status()).toBe(200) + const config = (await configResponse.json()) as { + permissionGroupId?: string | null + config?: Record | null + } + expect(config.permissionGroupId ?? null).toBeNull() + expect(config.config ?? null).toBeNull() + + const page = await context.newPage() + await page.goto(absoluteE2eUrl(`/workspace/${encodeURIComponent(workspaceId)}/settings/general`)) + const navigation = page + .getByRole('complementary', { name: 'Workspace sidebar' }) + .getByRole('navigation', { name: 'Workspace settings sections' }) + await expect(navigation).toHaveAttribute('aria-busy', 'false') + for (const restriction of dynamicRestrictionCases) { + await expect( + navigation.getByRole('button', { name: restriction.label, exact: true }) + ).toBeVisible() + } + + for (const restriction of dynamicRestrictionCases) { + const apiResponses: Array> = [] + if (restriction.sectionId === 'secrets') { + apiResponses.push( + waitForSameOriginResponse(page, 'GET', '/api/environment'), + waitForSameOriginResponse( + page, + 'GET', + `/api/workspaces/${encodeURIComponent(workspaceId)}/environment` + ) + ) + } else if (restriction.sectionId === 'mcp') { + apiResponses.push(waitForSameOriginResponse(page, 'GET', '/api/mcp/servers')) + } else if (restriction.sectionId === 'custom-tools') { + apiResponses.push(waitForSameOriginResponse(page, 'GET', '/api/tools/custom')) + } else if (restriction.sectionId === 'inbox') { + apiResponses.push( + waitForSameOriginResponse( + page, + 'GET', + `/api/workspaces/${encodeURIComponent(workspaceId)}/inbox` + ) + ) + } + + const documentResponse = await page.goto( + absoluteE2eUrl( + `/workspace/${encodeURIComponent(workspaceId)}/settings/${restriction.sectionId}` + ) + ) + expect(documentResponse?.status()).toBe(200) + const resolvedApiResponses = await Promise.all(apiResponses) + for (const apiResponse of resolvedApiResponses) expect(apiResponse.status()).toBe(200) + + if (restriction.sectionId === 'secrets') { + await expect(page.getByRole('region', { name: 'Workspace' })).toBeVisible() + } else if (restriction.sectionId === 'apikeys') { + const region = page.getByRole('region', { name: 'API keys data' }) + await expect(region).toHaveAttribute('data-api-keys-state', 'ready') + const createButton = page.getByRole('button', { name: 'Create API key', exact: true }) + await expect(createButton).toBeEnabled() + await createButton.click() + const dialog = page.getByRole('dialog', { name: 'Create new API key' }) + await expect(dialog.getByPlaceholder('e.g., Development, Production')).toBeVisible() + const workspaceType = dialog.getByRole('radio', { name: 'Workspace', exact: true }) + const personalType = dialog.getByRole('radio', { name: 'Personal', exact: true }) + if (authority === 'read') { + await expect(workspaceType).toHaveCount(0) + await expect(personalType).toHaveCount(0) + } else { + await expect(workspaceType).toBeVisible() + await expect(personalType).toBeVisible() + } + await page.keyboard.press('Escape') + } else if (restriction.sectionId === 'mcp') { + await expect( + page.getByText( + authority === 'read' + ? 'No MCP servers configured' + : 'Click "Add server" above to get started', + { exact: true } + ) + ).toBeVisible() + } else if (restriction.sectionId === 'custom-tools') { + await expect( + page.getByText( + authority === 'read' + ? 'No custom tools configured' + : 'Click "Add tool" above to get started', + { exact: true } + ) + ).toBeVisible() + } else { + const config = (await resolvedApiResponses[0].json()) as { + entitled?: boolean + enabled?: boolean + } + expect(config).toMatchObject({ entitled: true, enabled: false }) + await expect(page.getByRole('heading', { name: 'Sim mailer' })).toBeVisible() + await expect(page.getByText('Sim Mailer requires an active Max plan')).toHaveCount(0) + } + } +} + +function required(values: Record, key: string, label: string): string { + const value = values[key] + if (!value) throw new Error(`Missing ${label} binding: ${key}`) + return value +} diff --git a/apps/sim/e2e/settings/workflows/people.spec.ts b/apps/sim/e2e/settings/workflows/people.spec.ts new file mode 100644 index 00000000000..2ba0a99852a --- /dev/null +++ b/apps/sim/e2e/settings/workflows/people.spec.ts @@ -0,0 +1,355 @@ +import type { Locator } from '@playwright/test' +import { + deleteInvitationByEmail, + enterEmail, + expectOrganizationMembersReady, + expectTeammatesReady, + expectTeamWorkflowMemberBaseline, + findRosterInvitation, + findRosterMember, + getOrganizationRoster, + newPersonaPage, + primaryWorldIds, + restoreTeamWorkflowMember, + selectDropdownOption, + uniqueWorkflowEmail, + waitForSameOriginResponse, +} from './helpers' +import { expect, test } from './workflow-test' + +test('workspace invitation can be sent as Read and revoked without exposing its token', async ({ + contextForPersona, + personaManifest, + registerCleanup, +}) => { + const ids = primaryWorldIds(personaManifest) + const workspaceName = + personaManifest.worlds['settings-primary'].workspaceIdentities['team-invitation-workspace'].name + const email = uniqueWorkflowEmail('workspace-invite') + const { context, page } = await newPersonaPage(contextForPersona, 'paidOrganizationOwner') + + registerCleanup('remove workspace invitation', () => + deleteInvitationByEmail(context.request, ids.teamOrganizationId, email) + ) + + await page.goto( + `/workspace/${encodeURIComponent(ids.teamInvitationWorkspaceId)}/settings/teammates` + ) + const teammates = await expectTeammatesReady(page) + await page.getByRole('button', { name: 'Invite', exact: true }).click() + const modal = page.getByRole('dialog', { name: `Invite teammates to ${workspaceName}` }) + await enterEmail(modal, email) + await selectDropdownOption(modal, 'Admin', 'Read') + + const sendResponse = waitForSameOriginResponse(page, 'POST', '/api/workspaces/invitations/batch') + await modal.getByRole('button', { name: 'Send invites' }).click() + expect((await sendResponse).status()).toBe(200) + + const invitation = findRosterInvitation( + await getOrganizationRoster(context.request, ids.teamOrganizationId), + email + ) + expect(invitation).toMatchObject({ + email, + kind: 'workspace', + membershipIntent: 'internal', + role: 'member', + workspaces: [ + expect.objectContaining({ + workspaceId: ids.teamInvitationWorkspaceId, + permission: 'read', + }), + ], + }) + + const row = teammates + .getByRole('region', { name: 'Teammates' }) + .getByRole('group', { name: email }) + await expect(row.getByText('Invite pending')).toBeVisible() + await expect(row.getByRole('button', { name: 'Read', exact: true })).toBeDisabled() + + if (!invitation) throw new Error('Workspace invitation was not recoverable from safe roster') + await row.getByRole('button', { name: 'Teammate actions' }).click() + const revokeResponse = waitForSameOriginResponse( + page, + 'DELETE', + `/api/invitations/${invitation.id}` + ) + await page.getByRole('menuitem', { name: 'Revoke invite' }).click() + expect((await revokeResponse).status()).toBe(200) + await expect(row).toHaveCount(0) + expect( + findRosterInvitation( + await getOrganizationRoster(context.request, ids.teamOrganizationId), + email + ) + ).toBeUndefined() +}) + +test('organization invitation roles and workspace grants can be changed then revoked', async ({ + contextForPersona, + personaManifest, + registerCleanup, +}) => { + const ids = primaryWorldIds(personaManifest) + const workspaceName = + personaManifest.worlds['settings-primary'].workspaceIdentities['team-invitation-workspace'].name + const email = uniqueWorkflowEmail('organization-invite') + const { context, page } = await newPersonaPage(contextForPersona, 'paidOrganizationOwner') + + registerCleanup('remove organization invitation', () => + deleteInvitationByEmail(context.request, ids.teamOrganizationId, email) + ) + + await page.goto(`/workspace/${encodeURIComponent(ids.teamWorkspaceId)}/settings/organization`) + const membersRegion = await expectOrganizationMembersReady(page) + await page.getByRole('button', { name: 'Invite', exact: true }).click() + const modal = page.getByRole('dialog', { name: 'Invite teammates to organization' }) + await enterEmail(modal, email) + await modal.getByRole('button', { name: 'Select workspaces', exact: true }).click() + await page.getByRole('menuitem', { name: workspaceName, exact: true }).click() + await page.keyboard.press('Escape') + + const sendResponse = waitForSameOriginResponse( + page, + 'POST', + `/api/organizations/${ids.teamOrganizationId}/invitations` + ) + await modal.getByRole('button', { name: 'Send invites' }).click() + expect((await sendResponse).status()).toBe(200) + + const invitation = findRosterInvitation( + await getOrganizationRoster(context.request, ids.teamOrganizationId), + email + ) + expect(invitation).toMatchObject({ + kind: 'organization', + membershipIntent: 'internal', + role: 'member', + workspaces: [ + expect.objectContaining({ + workspaceId: ids.teamInvitationWorkspaceId, + permission: 'write', + }), + ], + }) + if (!invitation) throw new Error('Organization invitation was not recoverable from safe roster') + + const memberRow = memberRowInSection(membersRegion, 'Members', email) + const roleResponse = waitForSameOriginResponse(page, 'PATCH', `/api/invitations/${invitation.id}`) + await selectDropdownOption(memberRow, 'Member', 'Admin') + expect((await roleResponse).status()).toBe(200) + await expect(memberRow.getByRole('button', { name: 'Admin', exact: true })).toBeVisible() + + const workspaceRow = memberRowInSection(membersRegion, workspaceName, email) + const grantResponse = waitForSameOriginResponse( + page, + 'PATCH', + `/api/invitations/${invitation.id}` + ) + await selectDropdownOption(workspaceRow, 'Write', 'Read') + expect((await grantResponse).status()).toBe(200) + await expect(workspaceRow.getByRole('button', { name: 'Read', exact: true })).toBeVisible() + + const updated = findRosterInvitation( + await getOrganizationRoster(context.request, ids.teamOrganizationId), + email + ) + expect(updated).toMatchObject({ + role: 'admin', + workspaces: [ + expect.objectContaining({ + workspaceId: ids.teamInvitationWorkspaceId, + permission: 'read', + }), + ], + }) + + await memberRow.getByRole('button', { name: 'Member actions' }).click() + const revokeResponse = waitForSameOriginResponse( + page, + 'DELETE', + `/api/invitations/${invitation.id}` + ) + await page.getByRole('menuitem', { name: 'Revoke invite' }).click() + expect((await revokeResponse).status()).toBe(200) + await expect(memberRow).toHaveCount(0) +}) + +test('existing member workspace and organization lifecycle restores its exact baseline', async ({ + contextForPersona, + personaManifest, + registerCleanup, +}) => { + const ids = primaryWorldIds(personaManifest) + const targetPersona = personaManifest.personas.teamWorkflowMember + const invitationWorkspaceName = + personaManifest.worlds['settings-primary'].workspaceIdentities['team-invitation-workspace'].name + const anchorWorkspaceName = + personaManifest.worlds['settings-primary'].workspaceIdentities['team-workspace'].name + const { context: adminContext, page } = await newPersonaPage( + contextForPersona, + 'paidOrganizationOwner' + ) + const { context: targetContext } = await newPersonaPage(contextForPersona, 'teamWorkflowMember') + + const restore = () => + restoreTeamWorkflowMember({ + adminRequest: adminContext.request, + targetRequest: targetContext.request, + organizationId: ids.teamOrganizationId, + anchorWorkspaceId: ids.teamWorkspaceId, + invitationWorkspaceId: ids.teamInvitationWorkspaceId, + targetUserId: targetPersona.userId, + targetEmail: targetPersona.email, + }) + registerCleanup('restore team workflow member', restore) + await restore() + + await page.goto( + `/workspace/${encodeURIComponent(ids.teamInvitationWorkspaceId)}/settings/teammates` + ) + const teammates = await expectTeammatesReady(page) + await page.getByRole('button', { name: 'Invite', exact: true }).click() + const inviteModal = page.getByRole('dialog', { + name: `Invite teammates to ${invitationWorkspaceName}`, + }) + await enterEmail(inviteModal, targetPersona.email) + await selectDropdownOption(inviteModal, 'Admin', 'Read') + const directAddResponse = waitForSameOriginResponse( + page, + 'POST', + '/api/workspaces/invitations/batch' + ) + await inviteModal.getByRole('button', { name: 'Send invites' }).click() + const directAdd = await directAddResponse + expect(directAdd.status()).toBe(200) + const directAddBody = (await directAdd.json()) as { + added?: string[] + successful?: string[] + invitations?: Array> + } + expect(directAddBody.added).toEqual([targetPersona.email]) + expect(directAddBody.successful).toEqual([]) + expect(directAddBody.invitations).toEqual([ + expect.objectContaining({ + email: targetPersona.email, + workspaceId: ids.teamInvitationWorkspaceId, + permission: 'read', + outcome: 'added', + instantAdd: true, + }), + ]) + expect(directAddBody.invitations?.some((invitation) => 'token' in invitation)).toBe(false) + + const row = teammates + .getByRole('region', { name: 'Teammates' }) + .getByRole('group', { name: targetPersona.email }) + await expect(row.getByRole('button', { name: 'Read', exact: true })).toBeVisible() + + const roleResponse = waitForSameOriginResponse( + page, + 'PATCH', + `/api/workspaces/${ids.teamInvitationWorkspaceId}/permissions` + ) + await selectDropdownOption(row, 'Read', 'Write') + expect((await roleResponse).status()).toBe(200) + await expect(row.getByRole('button', { name: 'Write', exact: true })).toBeVisible() + + await row.getByRole('button', { name: 'Teammate actions' }).click() + const removeGrantResponse = waitForSameOriginResponse( + page, + 'DELETE', + `/api/workspaces/members/${targetPersona.userId}` + ) + await page.getByRole('menuitem', { name: 'Remove', exact: true }).click() + expect((await removeGrantResponse).status()).toBe(200) + await expect(row).toHaveCount(0) + + await page.goto(`/workspace/${encodeURIComponent(ids.teamWorkspaceId)}/settings/organization`) + let membersRegion = await expectOrganizationMembersReady(page) + let memberRow = memberRowInSection(membersRegion, 'Members', targetPersona.email) + const promoteResponse = waitForSameOriginResponse( + page, + 'PUT', + `/api/organizations/${ids.teamOrganizationId}/members/${targetPersona.userId}` + ) + await selectDropdownOption(memberRow, 'Member', 'Admin') + expect((await promoteResponse).status()).toBe(200) + + await page.reload() + membersRegion = await expectOrganizationMembersReady(page) + memberRow = memberRowInSection(membersRegion, 'Members', targetPersona.email) + await expect(memberRow.getByRole('button', { name: 'Admin', exact: true })).toBeVisible() + await expect( + memberRowInSection(membersRegion, anchorWorkspaceName, targetPersona.email).getByRole( + 'button', + { + name: 'Admin', + exact: true, + } + ) + ).toBeDisabled() + await expect( + memberRowInSection(membersRegion, invitationWorkspaceName, targetPersona.email).getByRole( + 'button', + { name: 'Admin', exact: true } + ) + ).toBeDisabled() + + const demoteResponse = waitForSameOriginResponse( + page, + 'PUT', + `/api/organizations/${ids.teamOrganizationId}/members/${targetPersona.userId}` + ) + await selectDropdownOption(memberRow, 'Admin', 'Member') + expect((await demoteResponse).status()).toBe(200) + await page.reload() + membersRegion = await expectOrganizationMembersReady(page) + await expect( + memberRowInSection(membersRegion, anchorWorkspaceName, targetPersona.email).getByRole( + 'button', + { + name: 'Read', + exact: true, + } + ) + ).toBeVisible() + await expect( + membersRegion + .getByRole('region', { name: invitationWorkspaceName }) + .getByRole('group', { name: targetPersona.email }) + ).toHaveCount(0) + + memberRow = memberRowInSection(membersRegion, 'Members', targetPersona.email) + await memberRow.getByRole('button', { name: 'Member actions' }).click() + await page.getByRole('menuitem', { name: 'Remove', exact: true }).click() + const confirmation = page.getByRole('dialog', { name: 'Remove Team Member' }) + const removeMemberResponse = waitForSameOriginResponse( + page, + 'DELETE', + `/api/organizations/${ids.teamOrganizationId}/members/${targetPersona.userId}` + ) + await confirmation.getByRole('button', { name: 'Remove', exact: true }).click() + expect((await removeMemberResponse).status()).toBe(200) + + const removedRoster = await getOrganizationRoster(adminContext.request, ids.teamOrganizationId) + expect(findRosterMember(removedRoster, targetPersona.email)).toBeUndefined() + expect(findRosterInvitation(removedRoster, targetPersona.email)).toBeUndefined() + + await restore() + await expectTeamWorkflowMemberBaseline({ + adminRequest: adminContext.request, + targetRequest: targetContext.request, + organizationId: ids.teamOrganizationId, + anchorWorkspaceId: ids.teamWorkspaceId, + invitationWorkspaceId: ids.teamInvitationWorkspaceId, + targetEmail: targetPersona.email, + }) +}) + +function memberRowInSection(membersRegion: Locator, sectionName: string, email: string): Locator { + return membersRegion + .getByRole('region', { name: sectionName, exact: true }) + .getByRole('group', { name: email, exact: true }) +} diff --git a/apps/sim/e2e/settings/workflows/workflow-test.ts b/apps/sim/e2e/settings/workflows/workflow-test.ts new file mode 100644 index 00000000000..026dacc1457 --- /dev/null +++ b/apps/sim/e2e/settings/workflows/workflow-test.ts @@ -0,0 +1,27 @@ +import type { TestInfo } from '@playwright/test' +import { test as personaTest } from '../../fixtures/persona-test' + +interface WorkflowFixtures { + workflowArtifactSafety: undefined +} + +export const test = personaTest.extend({ + workflowArtifactSafety: [ + async ({ browserName: _browserName }, use, testInfo) => { + assertWorkflowArtifactPolicy(testInfo) + await use(undefined) + }, + { auto: true }, + ], +}) + +export { expect } from '@playwright/test' + +function assertWorkflowArtifactPolicy(testInfo: TestInfo): void { + const { trace, screenshot, video } = testInfo.project.use + if (trace !== 'off' || screenshot !== 'only-on-failure' || video !== 'off') { + throw new Error( + 'People workflows must disable trace/video while retaining failure-only screenshots' + ) + } +} diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 11c3341d9cc..0b53a154090 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -48,29 +48,49 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon * id and the caller's admin status server-side from the workspace so gating is * never keyed off the session's active org. */ - const { data: userPermissionConfig, isPending: entitlementLoading } = - useUserPermissionConfig(workspaceId) - const { data: organizationBillingData, isPending: organizationBillingLoading } = - useOrganizationBilling(organizationId) + const { + data: userPermissionConfig, + isPending: entitlementLoading, + isError: entitlementError, + } = useUserPermissionConfig(workspaceId) + const { + data: organizationBillingData, + isPending: organizationBillingLoading, + isError: organizationBillingError, + } = useOrganizationBilling(organizationId) const currentUserIsOrgAdmin = isOrganizationAdmin - const { data: permissionGroups = [], isPending: groupsLoading } = usePermissionGroups( - organizationId, - !!organizationId && currentUserIsOrgAdmin - ) - const { data: organizationWorkspaces = [], isPending: workspacesLoading } = - useOrganizationWorkspaces(organizationId, !!organizationId && currentUserIsOrgAdmin) + const { + data: permissionGroups = [], + isPending: groupsLoading, + isError: groupsError, + } = usePermissionGroups(organizationId, !!organizationId && currentUserIsOrgAdmin) + const { + data: organizationWorkspaces = [], + isPending: workspacesLoading, + isError: workspacesError, + } = useOrganizationWorkspaces(organizationId, !!organizationId && currentUserIsOrgAdmin) const accessControlEnabledLocally = isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED')) const isEntitled = accessControlEnabledLocally || !!userPermissionConfig?.entitled || isEnterprise(organizationBillingData?.data?.subscriptionPlan) - const canManage = isEntitled && currentUserIsOrgAdmin && !!organizationId - + const hasLoadError = + (workspaceId ? entitlementError : organizationBillingError) || + (!!organizationId && currentUserIsOrgAdmin && (groupsError || workspacesError)) const isLoading = - (workspaceId ? entitlementLoading : organizationBillingLoading) || - (!!organizationId && currentUserIsOrgAdmin && groupsLoading) + !hasLoadError && + ((workspaceId ? entitlementLoading : organizationBillingLoading) || + (!!organizationId && currentUserIsOrgAdmin && (groupsLoading || workspacesLoading))) + const dataState = hasLoadError + ? 'error' + : isLoading + ? 'loading' + : isEntitled && currentUserIsOrgAdmin && !!organizationId + ? 'ready' + : 'denied' + const canManage = dataState === 'ready' const createPermissionGroup = useCreatePermissionGroup() @@ -138,36 +158,48 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon }, []) if (isLoading) { - return null + return
+ } + + if (hasLoadError) { + return ( +
+ Unable to load Access Control settings. +
+ ) } if (!canManage) { return ( - - {!organizationId - ? "Access Control applies to organization workspaces. This workspace isn't part of an organization." - : 'Only organization admins on Enterprise plans can manage Access Control settings.'} - +
+ + {!organizationId + ? "Access Control applies to organization workspaces. This workspace isn't part of an organization." + : 'Only organization admins on Enterprise plans can manage Access Control settings.'} + +
) } if (selectedGroup && organizationId) { return ( - setSelectedGroupId(null)} - onDeleted={() => setSelectedGroupId(null)} - /> +
+ setSelectedGroupId(null)} + onDeleted={() => setSelectedGroupId(null)} + /> +
) } return ( - <> +
- + {permissionGroups.length === 0 ? ( No permission groups yet. Click "Create group" to get started. @@ -198,6 +233,7 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon
) } diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index 410bf83ff04..3176bf5c0f6 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -163,6 +163,7 @@ export function useCancelWorkspaceInvitation() { interface ResendInvitationParams { invitationId: string workspaceId: string + organizationId?: string | null } /** @@ -182,6 +183,11 @@ export function useResendWorkspaceInvitation() { queryClient.invalidateQueries({ queryKey: invitationKeys.list(variables.workspaceId), }) + if (variables.organizationId) { + queryClient.invalidateQueries({ + queryKey: organizationKeys.roster(variables.organizationId), + }) + } }, }) } @@ -219,6 +225,9 @@ export function useRemoveWorkspaceMember() { queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.organizationId), }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.billing(variables.organizationId), + }) } }, }) diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index 3513c4c167d..9d8a7114175 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -17,6 +17,10 @@ import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-gr export interface PermissionConfigResult { config: PermissionGroupConfig isLoading: boolean + isError: boolean + /** Permission-group policy state only; excludes the independent integration allowlist query. */ + isPermissionLoading: boolean + isPermissionError: boolean isInPermissionGroup: boolean filterBlocks: (blocks: T[]) => T[] filterProviders: (providerIds: string[]) => string[] @@ -70,12 +74,19 @@ export function usePermissionConfig(): PermissionConfigResult { const params = useParams() const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined - const { data: permissionData, isLoading: isPermissionLoading } = - useUserPermissionConfig(workspaceId) - const { data: envAllowlistData, isLoading: isEnvAllowlistLoading } = - useAllowedIntegrationsFromEnv() + const { + data: permissionData, + isLoading: isPermissionLoading, + isError: isPermissionError, + } = useUserPermissionConfig(workspaceId) + const { + data: envAllowlistData, + isLoading: isEnvAllowlistLoading, + isError: isEnvAllowlistError, + } = useAllowedIntegrationsFromEnv() const isLoading = isPermissionLoading || isEnvAllowlistLoading + const isError = isPermissionError || isEnvAllowlistError const config = useMemo(() => { if (!permissionData?.config) { @@ -158,6 +169,9 @@ export function usePermissionConfig(): PermissionConfigResult { () => ({ config: mergedConfig, isLoading, + isError, + isPermissionLoading, + isPermissionError, isInPermissionGroup, filterBlocks, filterProviders, @@ -171,6 +185,9 @@ export function usePermissionConfig(): PermissionConfigResult { [ mergedConfig, isLoading, + isError, + isPermissionLoading, + isPermissionError, isInPermissionGroup, filterBlocks, filterProviders, diff --git a/apps/sim/playwright.config.ts b/apps/sim/playwright.config.ts index a47e69cd9bd..562373b940e 100644 --- a/apps/sim/playwright.config.ts +++ b/apps/sim/playwright.config.ts @@ -72,6 +72,11 @@ export default defineConfig({ dependencies: ['hosted-billing-chromium-credentials'], fullyParallel: false, workers: 1, + use: { + trace: 'off', + screenshot: 'only-on-failure', + video: 'off', + }, }, { name: 'hosted-billing-chromium-personas',