add role assignment page (#10115)

## Context
This PR introduces the "assignment" tab in the Role edit page, currently
allowing admin users to assign workspace members to specific roles.

Note: For now, a user can only have one role and a modal will warn you
if you try to re-assign a user to a new role.

## Test
<img width="648" alt="Screenshot 2025-02-10 at 17 59 21"
src="https://github.com/user-attachments/assets/dabd7a17-6aca-4d2b-95d8-46182f53e1e8"
/>
<img width="668" alt="Screenshot 2025-02-10 at 17 59 33"
src="https://github.com/user-attachments/assets/802aab7a-db67-4f83-9a44-35773df100f7"
/>
<img width="629" alt="Screenshot 2025-02-10 at 17 59 42"
src="https://github.com/user-attachments/assets/277db061-3f05-4ccd-8a83-7a96d6c1673e"
/>
This commit is contained in:
Weiko
2025-02-11 14:51:31 +01:00
committed by GitHub
parent 179d3ae2a4
commit 02ced028e5
26 changed files with 813 additions and 70 deletions

View File

@ -468,29 +468,6 @@ export type EnvironmentVariable = {
};
export enum EnvironmentVariablesGroup {
Authentication = 'Authentication',
Email = 'Email',
Logging = 'Logging',
Other = 'Other',
ServerConfig = 'ServerConfig',
Workspace = 'Workspace'
}
export type EnvironmentVariablesGroupData = {
__typename?: 'EnvironmentVariablesGroupData';
description: Scalars['String']['output'];
isHiddenOnLoad: Scalars['Boolean']['output'];
name: EnvironmentVariablesGroup;
subgroups: Array<EnvironmentVariablesSubgroupData>;
variables: Array<EnvironmentVariable>;
};
export type EnvironmentVariablesOutput = {
__typename?: 'EnvironmentVariablesOutput';
groups: Array<EnvironmentVariablesGroupData>;
};
export enum EnvironmentVariablesSubGroup {
BillingConfig = 'BillingConfig',
CaptchaConfig = 'CaptchaConfig',
CloudflareConfig = 'CloudflareConfig',
@ -498,10 +475,12 @@ export enum EnvironmentVariablesSubGroup {
ExceptionHandler = 'ExceptionHandler',
GoogleAuth = 'GoogleAuth',
LLM = 'LLM',
Logging = 'Logging',
MicrosoftAuth = 'MicrosoftAuth',
PasswordAuth = 'PasswordAuth',
Other = 'Other',
RateLimiting = 'RateLimiting',
SSL = 'SSL',
ServerConfig = 'ServerConfig',
ServerlessConfig = 'ServerlessConfig',
StorageConfig = 'StorageConfig',
SupportChatConfig = 'SupportChatConfig',
@ -509,13 +488,19 @@ export enum EnvironmentVariablesSubGroup {
TokensDuration = 'TokensDuration'
}
export type EnvironmentVariablesSubgroupData = {
__typename?: 'EnvironmentVariablesSubgroupData';
export type EnvironmentVariablesGroupData = {
__typename?: 'EnvironmentVariablesGroupData';
description: Scalars['String']['output'];
name: EnvironmentVariablesSubGroup;
isHiddenOnLoad: Scalars['Boolean']['output'];
name: EnvironmentVariablesGroup;
variables: Array<EnvironmentVariable>;
};
export type EnvironmentVariablesOutput = {
__typename?: 'EnvironmentVariablesOutput';
groups: Array<EnvironmentVariablesGroupData>;
};
export type ExecuteServerlessFunctionInput = {
/** Id of the serverless function to execute */
id: Scalars['UUID']['input'];
@ -2175,6 +2160,7 @@ export type WorkspaceMember = {
roles?: Maybe<Array<Role>>;
timeFormat?: Maybe<WorkspaceMemberTimeFormatEnum>;
timeZone?: Maybe<Scalars['String']['output']>;
userEmail: Scalars['String']['output'];
userWorkspaceId?: Maybe<Scalars['String']['output']>;
};

View File

@ -400,7 +400,6 @@ export type EnvironmentVariable = {
};
export enum EnvironmentVariablesGroup {
AuthenticationTokensDuration = 'AuthenticationTokensDuration',
BillingConfig = 'BillingConfig',
CaptchaConfig = 'CaptchaConfig',
CloudflareConfig = 'CloudflareConfig',
@ -1939,6 +1938,7 @@ export type WorkspaceMember = {
roles?: Maybe<Array<Role>>;
timeFormat?: Maybe<WorkspaceMemberTimeFormatEnum>;
timeZone?: Maybe<Scalars['String']>;
userEmail: Scalars['String'];
userWorkspaceId?: Maybe<Scalars['String']>;
};
@ -2243,10 +2243,20 @@ export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
export type UpdateLabPublicFeatureFlagMutation = { __typename?: 'Mutation', updateLabPublicFeatureFlag: { __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean } };
export type RoleFragmentFragment = { __typename?: 'Role', id: string, label: string, description?: string | null, canUpdateAllSettings: boolean, isEditable: boolean };
export type UpdateWorkspaceMemberRoleMutationVariables = Exact<{
workspaceMemberId: Scalars['String'];
roleId?: InputMaybe<Scalars['String']>;
}>;
export type UpdateWorkspaceMemberRoleMutation = { __typename?: 'Mutation', updateWorkspaceMemberRole: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, roles?: Array<{ __typename?: 'Role', id: string, label: string, description?: string | null, canUpdateAllSettings: boolean, isEditable: boolean }> | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } };
export type GetRolesQueryVariables = Exact<{ [key: string]: never; }>;
export type GetRolesQuery = { __typename?: 'Query', getRoles: Array<{ __typename?: 'Role', id: string, label: string, description?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, workspaceMembers: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> }> };
export type GetRolesQuery = { __typename?: 'Query', getRoles: Array<{ __typename?: 'Role', id: string, label: string, description?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, workspaceMembers: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> }> };
export type CreateOidcIdentityProviderMutationVariables = Exact<{
input: SetupOidcSsoInput;
@ -2281,7 +2291,7 @@ export type ListSsoIdentityProvidersByWorkspaceIdQueryVariables = Exact<{ [key:
export type ListSsoIdentityProvidersByWorkspaceIdQuery = { __typename?: 'Query', listSSOIdentityProvidersByWorkspaceId: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> };
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingsFeatures> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> };
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingsFeatures> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> };
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
@ -2298,7 +2308,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingsFeatures> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> } };
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingsFeatures> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> } };
export type ActivateWorkflowVersionMutationVariables = Exact<{
workflowVersionId: Scalars['String'];
@ -2382,7 +2392,7 @@ export type GetWorkspaceInvitationsQueryVariables = Exact<{ [key: string]: never
export type GetWorkspaceInvitationsQuery = { __typename?: 'Query', findWorkspaceInvitations: Array<{ __typename?: 'WorkspaceInvitation', id: any, email: string, expiresAt: string }> };
export type WorkspaceMemberQueryFragmentFragment = { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } };
export type WorkspaceMemberQueryFragmentFragment = { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } };
export type ActivateWorkspaceMutationVariables = Exact<{
input: ActivateWorkspaceInput;
@ -2521,6 +2531,15 @@ export const AvailableSsoIdentityProvidersFragmentFragmentDoc = gql`
}
}
`;
export const RoleFragmentFragmentDoc = gql`
fragment RoleFragment on Role {
id
label
description
canUpdateAllSettings
isEditable
}
`;
export const WorkspaceMemberQueryFragmentFragmentDoc = gql`
fragment WorkspaceMemberQueryFragment on WorkspaceMember {
id
@ -2531,6 +2550,7 @@ export const WorkspaceMemberQueryFragmentFragmentDoc = gql`
colorScheme
avatarUrl
locale
userEmail
timeZone
dateFormat
timeFormat
@ -3959,20 +3979,58 @@ export function useUpdateLabPublicFeatureFlagMutation(baseOptions?: Apollo.Mutat
export type UpdateLabPublicFeatureFlagMutationHookResult = ReturnType<typeof useUpdateLabPublicFeatureFlagMutation>;
export type UpdateLabPublicFeatureFlagMutationResult = Apollo.MutationResult<UpdateLabPublicFeatureFlagMutation>;
export type UpdateLabPublicFeatureFlagMutationOptions = Apollo.BaseMutationOptions<UpdateLabPublicFeatureFlagMutation, UpdateLabPublicFeatureFlagMutationVariables>;
export const UpdateWorkspaceMemberRoleDocument = gql`
mutation UpdateWorkspaceMemberRole($workspaceMemberId: String!, $roleId: String) {
updateWorkspaceMemberRole(
workspaceMemberId: $workspaceMemberId
roleId: $roleId
) {
...WorkspaceMemberQueryFragment
roles {
...RoleFragment
}
}
}
${WorkspaceMemberQueryFragmentFragmentDoc}
${RoleFragmentFragmentDoc}`;
export type UpdateWorkspaceMemberRoleMutationFn = Apollo.MutationFunction<UpdateWorkspaceMemberRoleMutation, UpdateWorkspaceMemberRoleMutationVariables>;
/**
* __useUpdateWorkspaceMemberRoleMutation__
*
* To run a mutation, you first call `useUpdateWorkspaceMemberRoleMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateWorkspaceMemberRoleMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [updateWorkspaceMemberRoleMutation, { data, loading, error }] = useUpdateWorkspaceMemberRoleMutation({
* variables: {
* workspaceMemberId: // value for 'workspaceMemberId'
* roleId: // value for 'roleId'
* },
* });
*/
export function useUpdateWorkspaceMemberRoleMutation(baseOptions?: Apollo.MutationHookOptions<UpdateWorkspaceMemberRoleMutation, UpdateWorkspaceMemberRoleMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpdateWorkspaceMemberRoleMutation, UpdateWorkspaceMemberRoleMutationVariables>(UpdateWorkspaceMemberRoleDocument, options);
}
export type UpdateWorkspaceMemberRoleMutationHookResult = ReturnType<typeof useUpdateWorkspaceMemberRoleMutation>;
export type UpdateWorkspaceMemberRoleMutationResult = Apollo.MutationResult<UpdateWorkspaceMemberRoleMutation>;
export type UpdateWorkspaceMemberRoleMutationOptions = Apollo.BaseMutationOptions<UpdateWorkspaceMemberRoleMutation, UpdateWorkspaceMemberRoleMutationVariables>;
export const GetRolesDocument = gql`
query GetRoles {
getRoles {
id
label
description
canUpdateAllSettings
isEditable
...RoleFragment
workspaceMembers {
...WorkspaceMemberQueryFragment
}
}
}
${WorkspaceMemberQueryFragmentFragmentDoc}`;
${RoleFragmentFragmentDoc}
${WorkspaceMemberQueryFragmentFragmentDoc}`;
/**
* __useGetRolesQuery__

View File

@ -1,8 +1,10 @@
import { useCreateAppRouter } from '@/app/hooks/useCreateAppRouter';
import { currentUserState } from '@/auth/states/currentUserState';
import { billingState } from '@/client-config/states/billingState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { RouterProvider } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const AppRouter = () => {
const billing = useRecoilValue(billingState);
@ -16,12 +18,17 @@ export const AppRouter = () => {
const isAdminPageEnabled = currentUser?.canImpersonate;
const isPermissionsEnabled = useIsFeatureEnabled(
FeatureFlagKey.IsPermissionsEnabled,
);
return (
<RouterProvider
router={useCreateAppRouter(
isBillingPageEnabled,
isFunctionSettingsEnabled,
isAdminPageEnabled,
isPermissionsEnabled,
)}
/>
);

View File

@ -267,12 +267,14 @@ type SettingsRoutesProps = {
isBillingEnabled?: boolean;
isFunctionSettingsEnabled?: boolean;
isAdminPageEnabled?: boolean;
isPermissionsEnabled?: boolean;
};
export const SettingsRoutes = ({
isBillingEnabled,
isFunctionSettingsEnabled,
isAdminPageEnabled,
isPermissionsEnabled,
}: SettingsRoutesProps) => (
<Suspense fallback={<SettingsSkeletonLoader />}>
<Routes>
@ -308,8 +310,15 @@ export const SettingsRoutes = ({
element={<SettingsObjectDetailPage />}
/>
<Route path={SettingsPath.NewObject} element={<SettingsNewObject />} />
<Route path={SettingsPath.Roles} element={<SettingsRoles />} />
<Route path={SettingsPath.RoleDetail} element={<SettingsRoleEdit />} />
{isPermissionsEnabled && (
<>
<Route path={SettingsPath.Roles} element={<SettingsRoles />} />
<Route
path={SettingsPath.RoleDetail}
element={<SettingsRoleEdit />}
/>
</>
)}
<Route path={SettingsPath.Developers} element={<SettingsDevelopers />} />
<Route
path={SettingsPath.DevelopersNewApiKey}

View File

@ -29,6 +29,7 @@ export const useCreateAppRouter = (
isBillingEnabled?: boolean,
isFunctionSettingsEnabled?: boolean,
isAdminPageEnabled?: boolean,
isPermissionsEnabled?: boolean,
) =>
createBrowserRouter(
createRoutesFromElements(
@ -63,6 +64,7 @@ export const useCreateAppRouter = (
isBillingEnabled={isBillingEnabled}
isFunctionSettingsEnabled={isFunctionSettingsEnabled}
isAdminPageEnabled={isAdminPageEnabled}
isPermissionsEnabled={isPermissionsEnabled}
/>
}
/>

View File

@ -209,6 +209,7 @@ export const queries = {
colorScheme
avatarUrl
locale
userEmail
timeZone
dateFormat
timeFormat

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const ROLE_FRAGMENT = gql`
fragment RoleFragment on Role {
id
label
description
canUpdateAllSettings
isEditable
}
`;

View File

@ -0,0 +1,22 @@
import { ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/roleFragment';
import { WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/workspaceMemberQueryFragment';
import { gql } from '@apollo/client';
export const UPDATE_WORKSPACE_MEMBER_ROLE = gql`
${WORKSPACE_MEMBER_QUERY_FRAGMENT}
${ROLE_FRAGMENT}
mutation UpdateWorkspaceMemberRole(
$workspaceMemberId: String!
$roleId: String
) {
updateWorkspaceMemberRole(
workspaceMemberId: $workspaceMemberId
roleId: $roleId
) {
...WorkspaceMemberQueryFragment
roles {
...RoleFragment
}
}
}
`;

View File

@ -1,15 +1,13 @@
import { ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/roleFragment';
import { WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/workspaceMemberQueryFragment';
import { gql } from '@apollo/client';
export const GET_ROLES = gql`
${WORKSPACE_MEMBER_QUERY_FRAGMENT}
${ROLE_FRAGMENT}
query GetRoles {
getRoles {
id
label
description
canUpdateAllSettings
isEditable
...RoleFragment
workspaceMembers {
...WorkspaceMemberQueryFragment
}

View File

@ -10,6 +10,7 @@ export const WORKSPACE_MEMBER_QUERY_FRAGMENT = gql`
colorScheme
avatarUrl
locale
userEmail
timeZone
dateFormat
timeFormat

View File

@ -2,7 +2,13 @@ import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { H3Title, IconLockOpen, IconUser, IconUserPlus } from 'twenty-ui';
import {
H3Title,
IconLockOpen,
IconSettings,
IconUser,
IconUserPlus,
} from 'twenty-ui';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsPath } from '@/types/SettingsPath';
@ -12,6 +18,7 @@ import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { useGetRolesQuery } from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { RolePermissions } from '~/pages/settings/roles/components/RolePermissions';
import { RoleSettings } from '~/pages/settings/roles/components/RoleSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { RoleAssignment } from './components/RoleAssignment';
@ -36,13 +43,16 @@ export const SETTINGS_ROLE_DETAIL_TABS = {
TABS_IDS: {
ASSIGNMENT: 'assignment',
PERMISSIONS: 'permissions',
SETTINGS: 'settings',
},
} as const;
export const SettingsRoleEdit = () => {
const { roleId = '' } = useParams();
const { data: rolesData, loading: rolesLoading } = useGetRolesQuery();
const navigateSettings = useNavigateSettings();
const { data: rolesData, loading: rolesLoading } = useGetRolesQuery({
fetchPolicy: 'network-only',
});
const role = rolesData?.getRoles.find((r) => r.id === roleId);
@ -71,6 +81,12 @@ export const SettingsRoleEdit = () => {
Icon: IconLockOpen,
hide: false,
},
{
id: SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.SETTINGS,
title: t`Settings`,
Icon: IconSettings,
hide: false,
},
];
const renderActiveTabContent = () => {
@ -79,6 +95,8 @@ export const SettingsRoleEdit = () => {
return <RoleAssignment role={role} />;
case SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.PERMISSIONS:
return <RolePermissions role={role} />;
case SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.SETTINGS:
return <RoleSettings role={role} />;
default:
return null;
}

View File

@ -55,8 +55,7 @@ const StyledAvatarGroup = styled.div`
margin-right: ${({ theme }) => theme.spacing(1)};
> * {
border: 2px solid ${({ theme }) => theme.background.primary};
margin-left: -8px;
margin-left: -5px;
&:first-of-type {
margin-left: 0;
@ -84,6 +83,11 @@ const StyledAvatarContainer = styled.div`
border: 0px;
`;
const StyledAssignedText = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
`;
export const SettingsRoles = () => {
const { t } = useLingui();
const isPermissionsEnabled = useIsFeatureEnabled(
@ -91,8 +95,9 @@ export const SettingsRoles = () => {
);
const theme = useTheme();
const navigateSettings = useNavigateSettings();
const { data: rolesData, loading: isRolesLoading } = useGetRolesQuery();
const { data: rolesData, loading: rolesLoading } = useGetRolesQuery({
fetchPolicy: 'network-only',
});
if (!isPermissionsEnabled) {
return null;
@ -131,7 +136,7 @@ export const SettingsRoles = () => {
<TableHeader align={'right'}></TableHeader>
</TableRow>
</StyledTableHeaderRow>
{!isRolesLoading &&
{!rolesLoading &&
rolesData?.getRoles.map((role) => (
<StyledTableRow
key={role.id}
@ -164,7 +169,7 @@ export const SettingsRoles = () => {
workspaceMember.name.firstName ?? ''
}
type="rounded"
size="sm"
size="md"
/>
</StyledAvatarContainer>
<AppTooltip
@ -178,7 +183,9 @@ export const SettingsRoles = () => {
</>
))}
</StyledAvatarGroup>
{role.workspaceMembers.length}
<StyledAssignedText>
{role.workspaceMembers.length}
</StyledAssignedText>
</StyledAssignedCell>
</TableCell>
<TableCell align={'right'}>

View File

@ -1,19 +1,227 @@
import { SettingsPath } from '@/types/SettingsPath';
import { TextInput } from '@/ui/input/components/TextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { Table } from '@/ui/layout/table/components/Table';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { H2Title, Section } from 'twenty-ui';
import { Role } from '~/generated-metadata/graphql';
import { useState } from 'react';
import { Button, H2Title, IconPlus, IconSearch, Section } from 'twenty-ui';
import { Role, WorkspaceMember } from '~/generated-metadata/graphql';
import {
GetRolesDocument,
useGetRolesQuery,
useUpdateWorkspaceMemberRoleMutation,
} from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { RoleAssignmentConfirmationModalMode } from '~/pages/settings/roles/types/RoleAssignmentConfirmationModalMode';
import { RoleAssignmentConfirmationModalSelectedWorkspaceMember } from '~/pages/settings/roles/types/RoleAssignmentConfirmationModalSelectedWorkspaceMember';
import { RoleAssignmentConfirmationModal } from './RoleAssignmentConfirmationModal';
import { RoleAssignmentTableHeader } from './RoleAssignmentTableHeader';
import { RoleAssignmentTableRow } from './RoleAssignmentTableRow';
import { RoleWorkspaceMemberPickerDropdown } from './RoleWorkspaceMemberPickerDropdown';
const StyledBottomSection = styled(Section)<{ hasRows: boolean }>`
${({ hasRows, theme }) =>
hasRows
? `
border-top: 1px solid ${theme.border.color.light};
margin-top: ${theme.spacing(2)};
padding-top: ${theme.spacing(4)};
`
: `
margin-top: ${theme.spacing(8)};
`}
display: flex;
justify-content: flex-end;
`;
const StyledEmptyText = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
justify-content: center;
margin-top: ${({ theme }) => theme.spacing(4)};
`;
const StyledSearchContainer = styled.div`
margin: ${({ theme }) => theme.spacing(2)} 0;
`;
const StyledSearchInput = styled(TextInput)`
input {
background: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
&:hover {
border: 1px solid ${({ theme }) => theme.border.color.medium};
}
}
`;
type RoleAssignmentProps = {
role: Pick<Role, 'id' | 'label' | 'canUpdateAllSettings'>;
role: Pick<Role, 'id' | 'label' | 'canUpdateAllSettings'> & {
workspaceMembers: Array<WorkspaceMember>;
};
};
// eslint-disable-next-line unused-imports/no-unused-vars
export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
const navigateSettings = useNavigateSettings();
const [updateWorkspaceMemberRole] = useUpdateWorkspaceMemberRoleMutation({
refetchQueries: [GetRolesDocument],
});
const [modalMode, setModalMode] =
useState<RoleAssignmentConfirmationModalMode | null>(null);
const [selectedWorkspaceMember, setSelectedWorkspaceMember] =
useState<RoleAssignmentConfirmationModalSelectedWorkspaceMember | null>(
null,
);
const { data: rolesData } = useGetRolesQuery();
const { closeDropdown } = useDropdown('role-member-select');
const [searchFilter, setSearchFilter] = useState('');
const workspaceMemberRoleMap = new Map<
string,
{ id: string; label: string }
>();
rolesData?.getRoles?.forEach((role) => {
role.workspaceMembers.forEach((member) => {
workspaceMemberRoleMap.set(member.id, { id: role.id, label: role.label });
});
});
const filteredWorkspaceMembers = !searchFilter
? role.workspaceMembers
: role.workspaceMembers.filter((member) => {
const searchTerm = searchFilter.toLowerCase();
const firstName = member.name.firstName?.toLowerCase() || '';
const lastName = member.name.lastName?.toLowerCase() || '';
const email = member.userEmail?.toLowerCase() || '';
return (
firstName.includes(searchTerm) ||
lastName.includes(searchTerm) ||
email.includes(searchTerm)
);
});
const handleModalClose = () => {
setModalMode(null);
setSelectedWorkspaceMember(null);
};
const handleSelectWorkspaceMember = (workspaceMember: WorkspaceMember) => {
const existingRole = workspaceMemberRoleMap.get(workspaceMember.id);
setSelectedWorkspaceMember({
id: workspaceMember.id,
name: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
role: existingRole,
});
setModalMode('assign');
closeDropdown();
};
const handleRemoveClick = (workspaceMember: WorkspaceMember) => {
setSelectedWorkspaceMember({
id: workspaceMember.id,
name: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
role: workspaceMemberRoleMap.get(workspaceMember.id),
});
setModalMode('remove');
};
const handleConfirm = async () => {
if (!selectedWorkspaceMember || !modalMode) return;
await updateWorkspaceMemberRole({
variables: {
workspaceMemberId: selectedWorkspaceMember.id,
roleId: modalMode === 'assign' ? role.id : null,
},
});
handleModalClose();
};
const handleRoleClick = (roleId: string) => {
navigateSettings(SettingsPath.RoleDetail, { roleId });
handleModalClose();
};
const handleSearchChange = (text: string) => {
setSearchFilter(text);
};
return (
<Section>
<H2Title
title={t`Assigned members`}
description={t`This Role is assigned to these workspace member.`}
/>
</Section>
<>
<Section>
<H2Title
title={t`Assigned members`}
description={t`This role is assigned to these workspace members.`}
/>
<StyledSearchContainer>
<StyledSearchInput
value={searchFilter}
onChange={handleSearchChange}
placeholder={t`Search a member`}
fullWidth
LeftIcon={IconSearch}
sizeVariant="lg"
/>
</StyledSearchContainer>
<Table>
<RoleAssignmentTableHeader />
{filteredWorkspaceMembers.map((workspaceMember) => (
<RoleAssignmentTableRow
key={workspaceMember.id}
workspaceMember={workspaceMember}
onRemove={() => handleRemoveClick(workspaceMember)}
/>
))}
{filteredWorkspaceMembers.length === 0 && (
<StyledEmptyText>
{searchFilter
? t`No members matching your search`
: t`No members assigned to this role yet`}
</StyledEmptyText>
)}
</Table>
</Section>
<StyledBottomSection hasRows={filteredWorkspaceMembers.length > 0}>
<Dropdown
dropdownId="role-member-select"
dropdownHotkeyScope={{ scope: 'roleAssignment' }}
clickableComponent={
<Button
Icon={IconPlus}
title={t`Assign to member`}
variant="secondary"
size="small"
/>
}
dropdownComponents={
<RoleWorkspaceMemberPickerDropdown
excludedWorkspaceMemberIds={role.workspaceMembers.map(
(workspaceMember) => workspaceMember.id,
)}
onSelect={handleSelectWorkspaceMember}
/>
}
/>
</StyledBottomSection>
{modalMode && selectedWorkspaceMember && (
<RoleAssignmentConfirmationModal
mode={modalMode}
selectedWorkspaceMember={selectedWorkspaceMember}
isOpen={true}
onClose={handleModalClose}
onConfirm={handleConfirm}
onRoleClick={handleRoleClick}
/>
)}
</>
);
};

View File

@ -0,0 +1,50 @@
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { t } from '@lingui/core/macro';
import { RoleAssignmentConfirmationModalSubtitle } from '~/pages/settings/roles/components/RoleAssignmentConfirmationModalSubtitle';
import { RoleAssignmentConfirmationModalMode } from '~/pages/settings/roles/types/RoleAssignmentConfirmationModalMode';
import { RoleAssignmentConfirmationModalSelectedWorkspaceMember } from '~/pages/settings/roles/types/RoleAssignmentConfirmationModalSelectedWorkspaceMember';
type RoleAssignmentConfirmationModalProps = {
mode: RoleAssignmentConfirmationModalMode;
selectedWorkspaceMember: RoleAssignmentConfirmationModalSelectedWorkspaceMember;
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
onRoleClick: (roleId: string) => void;
};
export const RoleAssignmentConfirmationModal = ({
mode,
selectedWorkspaceMember,
isOpen,
onClose,
onConfirm,
onRoleClick,
}: RoleAssignmentConfirmationModalProps) => {
const isAssignMode = mode === 'assign';
const hasExistingRole = !!selectedWorkspaceMember.role;
const workspaceMemberName = selectedWorkspaceMember.name;
const title = isAssignMode
? t`Assign ${workspaceMemberName}?`
: t`Remove ${workspaceMemberName}?`;
return (
<ConfirmationModal
isOpen={isOpen}
setIsOpen={onClose}
title={title}
subtitle={
<RoleAssignmentConfirmationModalSubtitle
mode={mode}
selectedWorkspaceMember={selectedWorkspaceMember}
onRoleClick={onRoleClick}
/>
}
onConfirmClick={onConfirm}
deleteButtonText={isAssignMode ? t`Confirm` : t`Remove`}
confirmButtonAccent={isAssignMode && !hasExistingRole ? 'blue' : 'danger'}
/>
);
};

View File

@ -0,0 +1,49 @@
import { SettingsCard } from '@/settings/components/SettingsCard';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconUser } from 'twenty-ui';
import { RoleAssignmentConfirmationModalMode } from '~/pages/settings/roles/types/RoleAssignmentConfirmationModalMode';
import { RoleAssignmentConfirmationModalSelectedWorkspaceMember } from '~/pages/settings/roles/types/RoleAssignmentConfirmationModalSelectedWorkspaceMember';
const StyledSettingsCardContainer = styled.div`
margin-top: ${({ theme }) => theme.spacing(2)};
`;
type RoleAssignmentConfirmationModalSubtitleProps = {
mode: RoleAssignmentConfirmationModalMode;
selectedWorkspaceMember: RoleAssignmentConfirmationModalSelectedWorkspaceMember;
onRoleClick: (roleId: string) => void;
};
export const RoleAssignmentConfirmationModalSubtitle = ({
mode,
selectedWorkspaceMember,
onRoleClick,
}: RoleAssignmentConfirmationModalSubtitleProps) => {
const isAssignMode = mode === 'assign';
const hasExistingRole = !!selectedWorkspaceMember.role;
const workspaceMemberName = selectedWorkspaceMember.name;
if (isAssignMode && hasExistingRole) {
return (
<>
{t`${workspaceMemberName} will be unassigned from the following role:`}
<StyledSettingsCardContainer>
<SettingsCard
title={selectedWorkspaceMember.role?.label || ''}
Icon={<IconUser />}
onClick={() =>
selectedWorkspaceMember.role &&
onRoleClick(selectedWorkspaceMember.role.id)
}
/>
</StyledSettingsCardContainer>
</>
);
}
return isAssignMode
? t`Are you sure you want to assign this role?`
: t`This member will be unassigned from this role.`;
};

View File

@ -0,0 +1,25 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
const StyledTableHeaderRow = styled(Table)`
margin-bottom: ${({ theme }) => theme.spacing(1.5)};
`;
type RoleAssignmentTableHeaderProps = {
className?: string;
};
export const RoleAssignmentTableHeader = ({
className,
}: RoleAssignmentTableHeaderProps) => (
<StyledTableHeaderRow className={className}>
<TableRow gridAutoColumns="150px 1fr 1fr">
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Email`}</TableHeader>
<TableHeader align={'right'} aria-label={t`Actions`}></TableHeader>
</TableRow>
</StyledTableHeaderRow>
);

View File

@ -0,0 +1,79 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import {
Avatar,
IconButton,
IconTrash,
OverflowingTextWithTooltip,
} from 'twenty-ui';
import { WorkspaceMember } from '~/generated-metadata/graphql';
const StyledTable = styled(Table)`
margin-top: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledIconWrapper = styled.div`
display: flex;
align-items: center;
margin-right: ${({ theme }) => theme.spacing(2)};
`;
const StyledButtonContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
margin-left: ${({ theme }) => theme.spacing(3)};
`;
type RoleAssignmentTableRowProps = {
workspaceMember: WorkspaceMember;
onRemove: (workspaceMemberId: string) => void;
};
export const RoleAssignmentTableRow = ({
workspaceMember,
onRemove,
}: RoleAssignmentTableRowProps) => {
const handleRemoveClick = (event: React.MouseEvent) => {
event.stopPropagation();
onRemove(workspaceMember.id);
};
return (
<StyledTable>
<TableRow gridAutoColumns="150px 1fr 1fr">
<TableCell>
<StyledIconWrapper>
<Avatar
avatarUrl={workspaceMember.avatarUrl}
placeholderColorSeed={workspaceMember.id}
placeholder={workspaceMember.name.firstName ?? ''}
type="rounded"
size="md"
/>
</StyledIconWrapper>
<OverflowingTextWithTooltip
text={`${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`}
/>
</TableCell>
<TableCell>
<OverflowingTextWithTooltip text={workspaceMember.userEmail} />
</TableCell>
<TableCell align={'right'}>
<StyledButtonContainer>
<IconButton
onClick={handleRemoveClick}
variant="tertiary"
size="medium"
Icon={IconTrash}
aria-label={t`Remove`}
/>
</StyledButtonContainer>
</TableCell>
</TableRow>
</StyledTable>
);
};

View File

@ -0,0 +1,46 @@
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput';
import { Role } from '~/generated-metadata/graphql';
const StyledInputsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledInputContainer = styled.div`
display: flex;
flex-direction: column;
`;
type RoleSettingsProps = {
role: Pick<Role, 'id' | 'label' | 'description'>;
};
export const RoleSettings = ({ role }: RoleSettingsProps) => {
return (
<>
<StyledInputsContainer>
<StyledInputContainer>
<IconPicker
disabled={true}
selectedIconKey={'IconUser'}
onChange={() => {}}
/>
</StyledInputContainer>
<TextInput value={role.label} disabled fullWidth />
</StyledInputsContainer>
<TextArea
minRows={4}
placeholder={t`Write a description`}
value={role.description || ''}
disabled
/>
</>
);
};

View File

@ -0,0 +1,71 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import styled from '@emotion/styled';
import { ChangeEvent, useState } from 'react';
import { WorkspaceMember } from '~/generated-metadata/graphql';
import { RoleWorkspaceMemberPickerDropdownContent } from './RoleWorkspaceMemberPickerDropdownContent';
const StyledWorkspaceMemberSelectContainer = styled.div`
max-height: ${({ theme }) => theme.spacing(50)};
overflow-y: auto;
`;
type RoleWorkspaceMemberPickerDropdownProps = {
excludedWorkspaceMemberIds: string[];
onSelect: (workspaceMember: WorkspaceMember) => void;
};
export const RoleWorkspaceMemberPickerDropdown = ({
excludedWorkspaceMemberIds,
onSelect,
}: RoleWorkspaceMemberPickerDropdownProps) => {
const [searchFilter, setSearchFilter] = useState('');
const { records: workspaceMembers, loading } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
filter: searchFilter
? {
or: [
{
name: { firstName: { ilike: `%${searchFilter}%` } },
},
{
name: { lastName: { ilike: `%${searchFilter}%` } },
},
{
userEmail: { ilike: `%${searchFilter}%` },
},
],
}
: undefined,
});
const filteredWorkspaceMembers = (workspaceMembers?.filter(
(workspaceMember) =>
!excludedWorkspaceMemberIds.includes(workspaceMember.id),
) ?? []) as WorkspaceMember[];
const handleSearchFilterChange = (event: ChangeEvent<HTMLInputElement>) => {
setSearchFilter(event.target.value);
};
return (
<DropdownMenuItemsContainer>
<DropdownMenuSearchInput
value={searchFilter}
onChange={handleSearchFilterChange}
placeholder="Search"
/>
<StyledWorkspaceMemberSelectContainer>
<RoleWorkspaceMemberPickerDropdownContent
loading={loading}
searchFilter={searchFilter}
filteredWorkspaceMembers={filteredWorkspaceMembers}
onSelect={onSelect}
/>
</StyledWorkspaceMemberSelectContainer>
</DropdownMenuItemsContainer>
);
};

View File

@ -0,0 +1,83 @@
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { Avatar } from 'twenty-ui';
import { WorkspaceMember } from '~/generated-metadata/graphql';
const StyledEmptyState = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
height: ${({ theme }) => theme.spacing(8)};
justify-content: flex-start;
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledWorkspaceMemberItem = styled.div`
align-items: center;
cursor: pointer;
display: flex;
font-size: ${({ theme }) => theme.font.size.md};
gap: ${({ theme }) => theme.spacing(2)};
min-width: ${({ theme }) => theme.spacing(45)};
padding: ${({ theme }) => theme.spacing(2)};
&:hover {
background: ${({ theme }) => theme.background.tertiary};
}
`;
const StyledWorkspaceMemberName = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
`;
type RoleWorkspaceMemberPickerDropdownContentProps = {
loading: boolean;
searchFilter: string;
filteredWorkspaceMembers: WorkspaceMember[];
onSelect: (workspaceMember: WorkspaceMember) => void;
};
export const RoleWorkspaceMemberPickerDropdownContent = ({
loading,
searchFilter,
filteredWorkspaceMembers,
onSelect,
}: RoleWorkspaceMemberPickerDropdownContentProps) => {
if (loading) {
return null;
}
if (!filteredWorkspaceMembers?.length) {
return (
<StyledEmptyState>
{searchFilter
? t`No members matching this search`
: t`No more members to add`}
</StyledEmptyState>
);
}
return (
<>
{filteredWorkspaceMembers.map((workspaceMember) => (
<StyledWorkspaceMemberItem
key={workspaceMember.id}
onClick={() => onSelect(workspaceMember)}
aria-label={`${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`}
>
<Avatar
type="rounded"
size="md"
placeholderColorSeed={workspaceMember.id}
placeholder={workspaceMember.name.firstName ?? ''}
/>
<StyledWorkspaceMemberName>
{workspaceMember.name.firstName} {workspaceMember.name.lastName}
</StyledWorkspaceMemberName>
</StyledWorkspaceMemberItem>
))}
</>
);
};

View File

@ -0,0 +1 @@
export type RoleAssignmentConfirmationModalMode = 'assign' | 'remove';

View File

@ -0,0 +1,5 @@
export type RoleAssignmentConfirmationModalSelectedWorkspaceMember = {
id: string;
name: string;
role?: { id: string; label: string };
};

View File

@ -20,6 +20,7 @@ export const mockedTimelineActivities: Array<TimelineActivity> = [
firstName: 'Tim',
lastName: 'Apple',
},
userEmail: 'tim@apple.com',
colorScheme: 'Light',
},
workspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7',
@ -45,6 +46,7 @@ export const mockedTimelineActivities: Array<TimelineActivity> = [
firstName: 'Tim',
lastName: 'Apple',
},
userEmail: 'tim@apple.com',
colorScheme: 'Light',
},
workspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7',
@ -70,6 +72,7 @@ export const mockedTimelineActivities: Array<TimelineActivity> = [
firstName: 'Tim',
lastName: 'Apple',
},
userEmail: 'tim@apple.com',
colorScheme: 'Light',
},
workspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7',
@ -102,6 +105,7 @@ export const mockedTimelineActivities: Array<TimelineActivity> = [
firstName: 'Jane',
lastName: 'Doe',
},
userEmail: 'jane@doe.com',
colorScheme: 'Light',
},
workspaceMemberId: '20202020-1553-45c6-a028-5a9064cce07f',
@ -128,6 +132,7 @@ export const mockedTimelineActivities: Array<TimelineActivity> = [
firstName: 'Tim',
lastName: 'Apple',
},
userEmail: 'tim@apple.com',
colorScheme: 'Light',
},
workspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7',

View File

@ -26,6 +26,9 @@ export class WorkspaceMember {
@Field(() => FullName)
name: FullName;
@Field({ nullable: false })
userEmail: string;
@Field({ nullable: false })
colorScheme: string;

View File

@ -3,6 +3,7 @@ import { Catch, ExceptionFilter } from '@nestjs/common';
import {
ForbiddenError,
InternalServerError,
NotFoundError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
PermissionsException,
@ -16,6 +17,9 @@ export class PermissionsGraphqlApiExceptionFilter implements ExceptionFilter {
case PermissionsExceptionCode.PERMISSION_DENIED:
case PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN:
throw new ForbiddenError(exception.message);
case PermissionsExceptionCode.ROLE_NOT_FOUND:
case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND:
throw new NotFoundError(exception.message);
default:
throw new InternalServerError(exception.message);
}

View File

@ -69,7 +69,6 @@ const StyledButton = styled('button', {
: theme.background.transparent.light
: theme.background.transparent.light};
border-width: 1px 1px 1px 1px !important;
opacity: ${disabled ? 0.24 : 1};
box-shadow: ${!disabled && focus
? `0 0 0 3px ${
!inverted
@ -112,7 +111,6 @@ const StyledButton = styled('button', {
}`
: 'none'};
color: ${!inverted ? theme.grayScale.gray0 : theme.color.blue};
opacity: ${disabled ? 0.24 : 1};
${disabled
? ''
: css`
@ -147,7 +145,6 @@ const StyledButton = styled('button', {
}`
: 'none'};
color: ${!inverted ? theme.background.primary : theme.color.red};
opacity: ${disabled ? 0.24 : 1};
${disabled
? ''
: css`
@ -194,7 +191,6 @@ const StyledButton = styled('button', {
: theme.background.transparent.medium
}`
: 'none'};
opacity: ${disabled ? 0.24 : 1};
color: ${!inverted
? !disabled
? theme.font.color.secondary
@ -241,7 +237,6 @@ const StyledButton = styled('button', {
: theme.background.transparent.medium
}`
: 'none'};
opacity: ${disabled ? 0.24 : 1};
color: ${!inverted
? !disabled
? theme.color.blue
@ -288,7 +283,6 @@ const StyledButton = styled('button', {
: theme.background.transparent.medium
}`
: 'none'};
opacity: ${disabled ? 0.24 : 1};
color: ${!inverted
? theme.font.color.danger
: theme.font.color.inverted};