feat: introduce role selector when inviting members to a workspace (#18085)
This PR adds an explicit role selector to the "Invite by email" flow,
requires a role choice before sending, and stores the selected role with
each invitation. The backend now accepts and persists `roleId` on
invitations and applies it when the invite is accepted, while keeping it
optional to avoid breaking existing clients and legacy invites.
---
### Frontend
- **Settings → Members → Invite by email**
- New **Role** dropdown (same `Select` pattern as member/API key role
selectors) between the email input and Invite button.
- Roles are loaded via `SettingsRolesQueryEffect` and
`settingsAllRolesSelector`; only roles with `canBeAssignedToUsers` are
shown.
- Role is **required**: form validates `roleId` (e.g.
`z.string().min(1)`) and the Invite button is disabled until a role is
selected and emails are valid.
- `WorkspaceInviteTeam` receives `roles` as a prop from the parent;
layout is responsive (e.g. stacked on small viewports).
- **Pending invitations table**
- New **Role** column showing the invitation’s role label (or "Unknown
role" for legacy invites without `roleId`), using the same roles source
for lookup.
- **Onboarding invite step**
- When sending invites during onboarding, the workspace **default role**
is used when available (`currentWorkspace?.defaultRole?.id`), so no role
selector is added there.
- **GraphQL**
- `sendInvitations` mutation accepts optional `roleId`;
`findWorkspaceInvitations` and resend mutation responses include
`roleId` on `WorkspaceInvitation`. Frontend types (e.g.
`WorkspaceInvitation`, hook variables) updated accordingly.
---
### Backend
- **API**
- `SendInvitationsInput` has an **optional** `roleId` (UUID, nullable).
The resolver normalises `null` to `undefined` so existing callers and
legacy flows are not broken.
- **Validation (when `roleId` is provided)**
- Role checks are centralised in **RoleValidationService**
(`RoleValidationModule`, in `metadata-modules/role-validation/`). It
validates that the role exists in the workspace and has
`canBeAssignedToUsers`, and throws a permissions-style error otherwise.
This avoids circular dependencies (e.g. `RoleModule` imports
`UserWorkspaceModule`, so invite/accept flows cannot depend on
`RoleModule`).
- **Send flow:** `WorkspaceInvitationResolver` and
`WorkspaceInvitationService.sendInvitations` both call
`RoleValidationService.validateRoleAssignableToUsersOrThrow` when
`roleId` is present (resolver before calling the service; service again
before creating tokens so that **resend** also validates the stored role
and fails fast if the role was deleted or made unassignable).
- **Accept flow:**
`UserWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace` uses the
same service in `resolveRoleIdForNewMember` when an invitation provides
a `roleId`, then falls back to `workspace.defaultRoleId` when not.
Role/default is resolved and validated before any user/workspace/member
creation.
- **Persistence**
- Invitation app tokens store `roleId` in `context` next to `email`
(`context: { email, roleId? }`). `generateInvitationToken` and
`createWorkspaceInvitation` accept an optional `roleId` and only add it
to `context` when defined.
- **Resend**
- Resend passes the existing invitation’s `context.roleId` into
`sendInvitations`. The service validates that role (when present) before
creating the new token, so if the role was deleted or made unassignable,
resend fails with a clear error instead of sending a broken link.
- **Response shape**
- `SendInvitationsOutput.result` remains `WorkspaceInvitation[]`. When
`usePersonalInvitation` is false we only push full invitation records
(from `castAppTokenToWorkspaceInvitationUtil`), so the result always
matches the GraphQL type (`id`, `email`, `roleId`, `expiresAt`).
- **Modules**
- `WorkspaceInvitationModule` and `UserWorkspaceModule` import
**RoleValidationModule** (not `RoleModule`) and inject
**RoleValidationService** for validation. `RoleModule` imports
`RoleValidationModule` and `RoleService` delegates to
`RoleValidationService` for the same validation where the module graph
allows.
---
### Backward compatibility
- **Optional `roleId`**: Clients that don’t send `roleId` (or send
`null`) are unchanged; invitations are created without a role and the
accept flow uses the workspace default role.
- **Legacy invitations**: App tokens with only `context.email` still
work; `context.roleId` is optional and the UI can show e.g. "Unknown
role" for those in the pending-invitations table.
This commit is contained in:
@@ -3035,6 +3035,7 @@ export type MutationSaveImapSmtpCaldavAccountArgs = {
|
||||
|
||||
export type MutationSendInvitationsArgs = {
|
||||
emails: Array<Scalars['String']>;
|
||||
roleId?: InputMaybe<Scalars['UUID']>;
|
||||
};
|
||||
|
||||
|
||||
@@ -5451,6 +5452,7 @@ export type WorkspaceInvitation = {
|
||||
email: Scalars['String'];
|
||||
expiresAt: Scalars['DateTime'];
|
||||
id: Scalars['UUID'];
|
||||
roleId?: Maybe<Scalars['UUID']>;
|
||||
};
|
||||
|
||||
export type WorkspaceInviteHashValid = {
|
||||
@@ -7172,19 +7174,20 @@ export type ResendWorkspaceInvitationMutationVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type ResendWorkspaceInvitationMutation = { __typename?: 'Mutation', resendWorkspaceInvitation: { __typename?: 'SendInvitations', success: boolean, errors: Array<string>, result: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, expiresAt: string }> } };
|
||||
export type ResendWorkspaceInvitationMutation = { __typename?: 'Mutation', resendWorkspaceInvitation: { __typename?: 'SendInvitations', success: boolean, errors: Array<string>, result: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, roleId?: string | null, expiresAt: string }> } };
|
||||
|
||||
export type SendInvitationsMutationVariables = Exact<{
|
||||
emails: Array<Scalars['String']> | Scalars['String'];
|
||||
roleId?: InputMaybe<Scalars['UUID']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type SendInvitationsMutation = { __typename?: 'Mutation', sendInvitations: { __typename?: 'SendInvitations', success: boolean, errors: Array<string>, result: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, expiresAt: string }> } };
|
||||
export type SendInvitationsMutation = { __typename?: 'Mutation', sendInvitations: { __typename?: 'SendInvitations', success: boolean, errors: Array<string>, result: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, roleId?: string | null, expiresAt: string }> } };
|
||||
|
||||
export type GetWorkspaceInvitationsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetWorkspaceInvitationsQuery = { __typename?: 'Query', findWorkspaceInvitations: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, expiresAt: string }> };
|
||||
export type GetWorkspaceInvitationsQuery = { __typename?: 'Query', findWorkspaceInvitations: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, roleId?: string | null, expiresAt: string }> };
|
||||
|
||||
export type DeletedWorkspaceMemberQueryFragmentFragment = { __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } };
|
||||
|
||||
@@ -16582,6 +16585,7 @@ export const ResendWorkspaceInvitationDocument = gql`
|
||||
... on WorkspaceInvitation {
|
||||
id
|
||||
email
|
||||
roleId
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
@@ -16615,14 +16619,15 @@ export type ResendWorkspaceInvitationMutationHookResult = ReturnType<typeof useR
|
||||
export type ResendWorkspaceInvitationMutationResult = Apollo.MutationResult<ResendWorkspaceInvitationMutation>;
|
||||
export type ResendWorkspaceInvitationMutationOptions = Apollo.BaseMutationOptions<ResendWorkspaceInvitationMutation, ResendWorkspaceInvitationMutationVariables>;
|
||||
export const SendInvitationsDocument = gql`
|
||||
mutation SendInvitations($emails: [String!]!) {
|
||||
sendInvitations(emails: $emails) {
|
||||
mutation SendInvitations($emails: [String!]!, $roleId: UUID) {
|
||||
sendInvitations(emails: $emails, roleId: $roleId) {
|
||||
success
|
||||
errors
|
||||
result {
|
||||
... on WorkspaceInvitation {
|
||||
id
|
||||
email
|
||||
roleId
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
@@ -16645,6 +16650,7 @@ export type SendInvitationsMutationFn = Apollo.MutationFunction<SendInvitationsM
|
||||
* const [sendInvitationsMutation, { data, loading, error }] = useSendInvitationsMutation({
|
||||
* variables: {
|
||||
* emails: // value for 'emails'
|
||||
* roleId: // value for 'roleId'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
@@ -16660,6 +16666,7 @@ export const GetWorkspaceInvitationsDocument = gql`
|
||||
findWorkspaceInvitations {
|
||||
id
|
||||
email
|
||||
roleId
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
+1
-5
@@ -3,10 +3,8 @@ import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { SettingsRoleDefaultRole } from '@/settings/roles/components/SettingsRolesDefaultRole';
|
||||
|
||||
import { SettingsRolesList } from '@/settings/roles/components/SettingsRolesList';
|
||||
import { useSettingsAllRoles } from '@/settings/roles/hooks/useSettingsAllRoles';
|
||||
import { settingsRolesIsLoadingState } from '@/settings/roles/states/settingsRolesIsLoadingState';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
@@ -16,10 +14,9 @@ import { H3Title } from 'twenty-ui/display';
|
||||
export const SettingsRolesContainer = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const settingsAllRoles = useSettingsAllRoles();
|
||||
const settingsRolesIsLoading = useAtomStateValue(settingsRolesIsLoadingState);
|
||||
|
||||
if (settingsRolesIsLoading && !settingsAllRoles) {
|
||||
if (settingsRolesIsLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -36,7 +33,6 @@ export const SettingsRolesContainer = () => {
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
<SettingsRolesList />
|
||||
<SettingsRoleDefaultRole roles={settingsAllRoles} />
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
);
|
||||
|
||||
+5
-5
@@ -3,9 +3,10 @@ import {
|
||||
currentWorkspaceState,
|
||||
} from '@/auth/states/currentWorkspaceState';
|
||||
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
|
||||
import { type RoleWithPartialMembers } from '@/settings/roles/types/RoleWithPartialMembers';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { H2Title, IconUserPin, useIcons } from 'twenty-ui/display';
|
||||
import { Card, Section } from 'twenty-ui/layout';
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
type UpdateWorkspaceMutation,
|
||||
useUpdateWorkspaceMutation,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { type RoleWithPartialMembers } from '@/settings/roles/types/RoleWithPartialMembers';
|
||||
|
||||
type SettingsRoleDefaultRoleProps = {
|
||||
roles: RoleWithPartialMembers[];
|
||||
@@ -70,14 +70,14 @@ export const SettingsRoleDefaultRole = ({
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Options`}
|
||||
description={t`Adjust the role-related settings`}
|
||||
title={t`Default Role`}
|
||||
description={t`Assigned to users who join via invite link, approved domain, or SSO, and used as fallback when an assigned role is deleted`}
|
||||
/>
|
||||
<Card rounded>
|
||||
<SettingsOptionCardContentSelect
|
||||
Icon={IconUserPin}
|
||||
title={t`Default Role`}
|
||||
description={t`Set a default role for this workspace`}
|
||||
description={t`Set a default for this workspace`}
|
||||
>
|
||||
<Select
|
||||
selectSizeVariant="small"
|
||||
|
||||
+1
@@ -9,6 +9,7 @@ export const RESEND_WORKSPACE_INVITATION = gql`
|
||||
... on WorkspaceInvitation {
|
||||
id
|
||||
email
|
||||
roleId
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,14 +1,15 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const SEND_INVITATIONS = gql`
|
||||
mutation SendInvitations($emails: [String!]!) {
|
||||
sendInvitations(emails: $emails) {
|
||||
mutation SendInvitations($emails: [String!]!, $roleId: UUID) {
|
||||
sendInvitations(emails: $emails, roleId: $roleId) {
|
||||
success
|
||||
errors
|
||||
result {
|
||||
... on WorkspaceInvitation {
|
||||
id
|
||||
email
|
||||
roleId
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ export const GET_WORKSPACE_INVITATIONS = gql`
|
||||
findWorkspaceInvitations {
|
||||
id
|
||||
email
|
||||
roleId
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -18,7 +18,23 @@ describe('useCreateWorkspaceInvitation', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Send invitations', async () => {
|
||||
it('Send invitations with role', async () => {
|
||||
const params = { emails: ['test@test.com'], roleId: 'role-id' };
|
||||
renderHook(
|
||||
() => {
|
||||
const { sendInvitation } = useCreateWorkspaceInvitation();
|
||||
sendInvitation(params);
|
||||
},
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(mutationSendInvitationsCallSpy).toHaveBeenCalledWith({
|
||||
onCompleted: expect.any(Function),
|
||||
variables: params,
|
||||
});
|
||||
});
|
||||
|
||||
it('Send invitations without role uses default', async () => {
|
||||
const params = { emails: ['test@test.com'] };
|
||||
renderHook(
|
||||
() => {
|
||||
|
||||
+4
-2
@@ -10,9 +10,11 @@ export const useCreateWorkspaceInvitation = () => {
|
||||
|
||||
const setWorkspaceInvitations = useSetAtomState(workspaceInvitationsState);
|
||||
|
||||
const sendInvitation = async (emails: SendInvitationsMutationVariables) => {
|
||||
const sendInvitation = async (
|
||||
variables: SendInvitationsMutationVariables,
|
||||
) => {
|
||||
return await sendInvitationsMutation({
|
||||
variables: emails,
|
||||
variables,
|
||||
onCompleted: (data) => {
|
||||
setWorkspaceInvitations((workspaceInvitations) => [
|
||||
...workspaceInvitations,
|
||||
|
||||
@@ -32,5 +32,6 @@ export type WorkspaceInvitation = {
|
||||
__typename: 'WorkspaceInvitation';
|
||||
id: string;
|
||||
email: string;
|
||||
roleId?: string | null;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
@@ -4,88 +4,143 @@ import { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type RoleWithPartialMembers } from '@/settings/roles/types/RoleWithPartialMembers';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
|
||||
import { useCreateWorkspaceInvitation } from '@/workspace-invitation/hooks/useCreateWorkspaceInvitation';
|
||||
import { sanitizeEmailList } from '@/workspace/utils/sanitizeEmailList';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconSend } from 'twenty-ui/display';
|
||||
import {
|
||||
IconLock,
|
||||
IconSend,
|
||||
IconUser,
|
||||
useIcons,
|
||||
type IconComponent,
|
||||
} from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { useCreateWorkspaceInvitation } from '@/workspace-invitation/hooks/useCreateWorkspaceInvitation';
|
||||
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
padding-bottom: ${({ theme }) => theme.spacing(3)};
|
||||
|
||||
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledLinkContainer = styled.div`
|
||||
flex: 1;
|
||||
margin-right: ${({ theme }) => theme.spacing(2)};
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledRoleContainer = styled.div`
|
||||
flex: 0 0 130px;
|
||||
min-width: 130px;
|
||||
|
||||
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const emailsEmptyErrorMessage = msg`Emails should not be empty`;
|
||||
|
||||
const validationSchema = z
|
||||
.object({
|
||||
emails: z.string().superRefine((value, ctx) => {
|
||||
if (!value.length) {
|
||||
return;
|
||||
const validationSchema = z.object({
|
||||
emails: z.string().superRefine((value, ctx) => {
|
||||
if (!value.length) {
|
||||
return;
|
||||
}
|
||||
const emails = sanitizeEmailList(value.split(','));
|
||||
if (emails.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: i18n._(emailsEmptyErrorMessage),
|
||||
});
|
||||
}
|
||||
const invalidEmails: string[] = [];
|
||||
for (const email of emails) {
|
||||
const result = z.email().safeParse(email);
|
||||
if (!result.success) {
|
||||
invalidEmails.push(email);
|
||||
}
|
||||
const emails = sanitizeEmailList(value.split(','));
|
||||
if (emails.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: i18n._(emailsEmptyErrorMessage),
|
||||
});
|
||||
}
|
||||
const invalidEmails: string[] = [];
|
||||
for (const email of emails) {
|
||||
const result = z.email().safeParse(email);
|
||||
if (!result.success) {
|
||||
invalidEmails.push(email);
|
||||
}
|
||||
}
|
||||
if (invalidEmails.length > 0) {
|
||||
const invalidEmailsList = invalidEmails.join(', ');
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message:
|
||||
invalidEmails.length > 1
|
||||
? `Invalid emails: ${invalidEmailsList}`
|
||||
: `Invalid email: ${invalidEmailsList}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
})
|
||||
.required();
|
||||
}
|
||||
if (invalidEmails.length > 0) {
|
||||
const invalidEmailsList = invalidEmails.join(', ');
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message:
|
||||
invalidEmails.length > 1
|
||||
? `Invalid emails: ${invalidEmailsList}`
|
||||
: `Invalid email: ${invalidEmailsList}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
roleId: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormInput = {
|
||||
emails: string;
|
||||
roleId?: string;
|
||||
};
|
||||
|
||||
export const WorkspaceInviteTeam = () => {
|
||||
type WorkspaceInviteTeamProps = {
|
||||
roles: RoleWithPartialMembers[];
|
||||
};
|
||||
|
||||
export const WorkspaceInviteTeam = ({ roles }: WorkspaceInviteTeamProps) => {
|
||||
const { t } = useLingui();
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
const { sendInvitation } = useCreateWorkspaceInvitation();
|
||||
|
||||
const roleOptions: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
Icon?: IconComponent;
|
||||
}> = roles
|
||||
.filter((role) => role.canBeAssignedToUsers)
|
||||
.map((role) => ({
|
||||
label: role.label,
|
||||
value: role.id,
|
||||
Icon: getIcon(role.icon) ?? IconUser,
|
||||
}));
|
||||
|
||||
const emptyRoleOption = {
|
||||
label: t`Default role`,
|
||||
value: '',
|
||||
Icon: IconLock,
|
||||
};
|
||||
|
||||
const { reset, handleSubmit, control, formState, watch } = useForm<FormInput>(
|
||||
{
|
||||
mode: 'onSubmit',
|
||||
resolver: zodResolver(validationSchema),
|
||||
defaultValues: {
|
||||
emails: '',
|
||||
roleId: '',
|
||||
},
|
||||
},
|
||||
);
|
||||
const isEmailsEmpty = !watch('emails');
|
||||
|
||||
const submit = handleSubmit(async ({ emails }) => {
|
||||
const submit = handleSubmit(async ({ emails, roleId }) => {
|
||||
const emailsList = sanitizeEmailList(emails.split(','));
|
||||
const { data } = await sendInvitation({ emails: emailsList });
|
||||
const { data } = await sendInvitation({
|
||||
emails: emailsList,
|
||||
...(roleId ? { roleId } : {}),
|
||||
});
|
||||
if (!isDefined(data)) {
|
||||
return;
|
||||
}
|
||||
@@ -142,6 +197,26 @@ export const WorkspaceInviteTeam = () => {
|
||||
}}
|
||||
/>
|
||||
</StyledLinkContainer>
|
||||
<StyledRoleContainer>
|
||||
<Controller
|
||||
name="roleId"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<Select
|
||||
dropdownId="workspace-invite-team-role"
|
||||
options={roleOptions}
|
||||
emptyOption={emptyRoleOption}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
withSearchInput
|
||||
fullWidth
|
||||
disabled={roleOptions.length === 0}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</StyledRoleContainer>
|
||||
<Button
|
||||
Icon={IconSend}
|
||||
variant="primary"
|
||||
|
||||
@@ -129,6 +129,12 @@ export const InviteTeam = () => {
|
||||
.filter((email) => email.length > 0),
|
||||
),
|
||||
);
|
||||
|
||||
if (emails.length === 0) {
|
||||
setNextOnboardingStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await sendInvitation({ emails });
|
||||
|
||||
if (isDefined(result.errors)) {
|
||||
|
||||
@@ -22,7 +22,6 @@ import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
|
||||
import { WorkspaceInviteLink } from '@/workspace/components/WorkspaceInviteLink';
|
||||
import { WorkspaceInviteTeam } from '@/workspace/components/WorkspaceInviteTeam';
|
||||
import { type ApolloError } from '@apollo/client';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import {
|
||||
generateILikeFiltersForCompositeFields,
|
||||
@@ -45,6 +44,8 @@ import { IconButton } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { useGetWorkspaceInvitationsQuery } from '~/generated-metadata/graphql';
|
||||
|
||||
import { SettingsRolesQueryEffect } from '@/settings/roles/components/SettingsRolesQueryEffect';
|
||||
import { useSettingsAllRoles } from '@/settings/roles/hooks/useSettingsAllRoles';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
import { normalizeSearchText } from '~/utils/normalizeSearchText';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
@@ -57,7 +58,13 @@ const StyledButtonContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-left: ${({ theme }) => theme.spacing(3)};
|
||||
flex-shrink: 0;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
margin-left: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledExpiresInHeader = styled.span`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledTable = styled(Table)<{ hasMoreRows?: boolean }>`
|
||||
@@ -77,6 +84,11 @@ const StyledTextContainerWithEllipsis = styled.div`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledInvitationTableCell = styled(TableCell)`
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledSearchContainer = styled.div`
|
||||
padding-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
@@ -119,6 +131,12 @@ export const SettingsWorkspaceMembers = () => {
|
||||
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
|
||||
|
||||
const [debouncedSearchFilter] = useDebounce(searchFilter, 300);
|
||||
const roles = useSettingsAllRoles();
|
||||
|
||||
const rolesById = new Map<string, (typeof roles)[number]>();
|
||||
roles.forEach((role) => {
|
||||
rolesById.set(role.id, role);
|
||||
});
|
||||
|
||||
const searchServerFilter = useMemo(() => {
|
||||
if (!debouncedSearchFilter?.trim()) return undefined;
|
||||
@@ -209,10 +227,15 @@ export const SettingsWorkspaceMembers = () => {
|
||||
});
|
||||
|
||||
const getExpiresAtText = (expiresAt: string) => {
|
||||
const expiresAtDate = new Date(expiresAt);
|
||||
return expiresAtDate < new Date()
|
||||
? t`Expired`
|
||||
: formatDistanceToNow(new Date(expiresAt));
|
||||
const msLeft = new Date(expiresAt).getTime() - Date.now();
|
||||
|
||||
if (msLeft <= 0) return t`Expired`;
|
||||
|
||||
const daysLeft = Math.ceil(msLeft / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysLeft === 1) return t`1 day`;
|
||||
|
||||
return t`${daysLeft} days`;
|
||||
};
|
||||
|
||||
const optimizedWorkspaceMembers = useMemo(() => {
|
||||
@@ -240,211 +263,236 @@ export const SettingsWorkspaceMembers = () => {
|
||||
}, [workspaceMembers, searchFilter]);
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title={t`Members`}
|
||||
links={[
|
||||
{
|
||||
children: <Trans>Workspace</Trans>,
|
||||
href: getSettingsPath(SettingsPath.Workspace),
|
||||
},
|
||||
{ children: <Trans>Members</Trans> },
|
||||
]}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
{currentWorkspace?.inviteHash &&
|
||||
currentWorkspace?.isPublicInviteLinkEnabled && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Invite by link`}
|
||||
description={t`Share this link to invite users to join your workspace`}
|
||||
<>
|
||||
<SettingsRolesQueryEffect />
|
||||
<SubMenuTopBarContainer
|
||||
title={t`Members`}
|
||||
links={[
|
||||
{
|
||||
children: <Trans>Workspace</Trans>,
|
||||
href: getSettingsPath(SettingsPath.Workspace),
|
||||
},
|
||||
{ children: <Trans>Members</Trans> },
|
||||
]}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
{currentWorkspace?.inviteHash &&
|
||||
currentWorkspace?.isPublicInviteLinkEnabled && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Invite by link`}
|
||||
description={t`Share this link to invite users to join your workspace`}
|
||||
/>
|
||||
<WorkspaceInviteLink
|
||||
inviteLink={`${window.location.origin}/invite/${currentWorkspace?.inviteHash}`}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Invite by email`}
|
||||
description={t`Send an invite email to your team`}
|
||||
/>
|
||||
<WorkspaceInviteTeam roles={roles} />
|
||||
{isNonEmptyArray(workspaceInvitations) && (
|
||||
<StyledTable>
|
||||
<TableRow
|
||||
gridAutoColumns="2fr 1fr 1fr 80px"
|
||||
mobileGridAutoColumns="2fr 1fr 1fr 72px"
|
||||
>
|
||||
<TableHeader>
|
||||
<Trans>Email</Trans>
|
||||
</TableHeader>
|
||||
<TableHeader>
|
||||
<Trans>Role</Trans>
|
||||
</TableHeader>
|
||||
<TableHeader align="center">
|
||||
<StyledExpiresInHeader>
|
||||
<Trans>Expires in</Trans>
|
||||
</StyledExpiresInHeader>
|
||||
</TableHeader>
|
||||
<TableHeader></TableHeader>
|
||||
</TableRow>
|
||||
<StyledTableRows>
|
||||
{workspaceInvitations?.map((workspaceInvitation) => (
|
||||
<TableRow
|
||||
gridAutoColumns="2fr 1fr 1fr 80px"
|
||||
mobileGridAutoColumns="2fr 1fr 1fr 72px"
|
||||
key={workspaceInvitation.id}
|
||||
>
|
||||
<StyledInvitationTableCell>
|
||||
<StyledIconWrapper>
|
||||
<IconMail
|
||||
size={theme.icon.size.md}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
/>
|
||||
</StyledIconWrapper>
|
||||
<StyledTextContainerWithEllipsis
|
||||
id={`invitation-email-${workspaceInvitation.id}`}
|
||||
>
|
||||
{workspaceInvitation.email}
|
||||
</StyledTextContainerWithEllipsis>
|
||||
<AppTooltip
|
||||
anchorSelect={`#invitation-email-${workspaceInvitation.id}`}
|
||||
content={workspaceInvitation.email}
|
||||
noArrow
|
||||
place="top"
|
||||
positionStrategy="fixed"
|
||||
delay={TooltipDelay.shortDelay}
|
||||
/>
|
||||
</StyledInvitationTableCell>
|
||||
<StyledInvitationTableCell>
|
||||
<StyledTextContainerWithEllipsis>
|
||||
{rolesById.get(workspaceInvitation.roleId ?? '')
|
||||
?.label ?? t`Default role`}
|
||||
</StyledTextContainerWithEllipsis>
|
||||
</StyledInvitationTableCell>
|
||||
<TableCell align="center">
|
||||
<Status
|
||||
color="gray"
|
||||
text={getExpiresAtText(workspaceInvitation.expiresAt)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<StyledButtonContainer>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
handleResendWorkspaceInvitation(
|
||||
workspaceInvitation.id,
|
||||
);
|
||||
}}
|
||||
variant="tertiary"
|
||||
size="medium"
|
||||
Icon={IconReload}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
handleRemoveWorkspaceInvitation(
|
||||
workspaceInvitation.id,
|
||||
);
|
||||
}}
|
||||
variant="tertiary"
|
||||
size="medium"
|
||||
Icon={IconTrash}
|
||||
/>
|
||||
</StyledButtonContainer>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</StyledTableRows>
|
||||
</StyledTable>
|
||||
)}
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Manage Members`}
|
||||
description={t`Manage the members of your workspace here`}
|
||||
/>
|
||||
<StyledSearchContainer>
|
||||
<StyledSearchInput
|
||||
instanceId="workspace-members-search"
|
||||
value={searchFilter}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={t`Search a team member...`}
|
||||
fullWidth
|
||||
LeftIcon={IconSearch}
|
||||
sizeVariant="lg"
|
||||
/>
|
||||
<WorkspaceInviteLink
|
||||
inviteLink={`${window.location.origin}/invite/${currentWorkspace?.inviteHash}`}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Invite by email`}
|
||||
description={t`Send an invite email to your team`}
|
||||
/>
|
||||
<WorkspaceInviteTeam />
|
||||
{isNonEmptyArray(workspaceInvitations) && (
|
||||
<StyledTable>
|
||||
</StyledSearchContainer>
|
||||
<StyledTable hasMoreRows={hasNextPage}>
|
||||
<TableRow
|
||||
gridAutoColumns="250px 1fr 1fr"
|
||||
mobileGridAutoColumns="100px 1fr 1fr"
|
||||
gridAutoColumns="150px 1fr 40px"
|
||||
mobileGridAutoColumns="100px 1fr 32px"
|
||||
>
|
||||
<TableHeader>
|
||||
<Trans>Name</Trans>
|
||||
</TableHeader>
|
||||
<TableHeader>
|
||||
<Trans>Email</Trans>
|
||||
</TableHeader>
|
||||
<TableHeader align="center">
|
||||
<Trans>Expires in</Trans>
|
||||
</TableHeader>
|
||||
<TableHeader></TableHeader>
|
||||
<TableHeader align="right"></TableHeader>
|
||||
</TableRow>
|
||||
<StyledTableRows>
|
||||
{workspaceInvitations?.map((workspaceInvitation) => (
|
||||
<TableRow
|
||||
gridAutoColumns="250px 1fr 1fr"
|
||||
mobileGridAutoColumns="100px 1fr 1fr"
|
||||
key={workspaceInvitation.id}
|
||||
>
|
||||
<TableCell>
|
||||
<StyledIconWrapper>
|
||||
<IconMail
|
||||
size={theme.icon.size.md}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
{optimizedWorkspaceMembers.length > 0 ? (
|
||||
optimizedWorkspaceMembers.map((workspaceMember) => (
|
||||
<StyledClickableTableRow
|
||||
gridAutoColumns="150px 1fr 40px"
|
||||
mobileGridAutoColumns="100px 1fr 32px"
|
||||
key={workspaceMember.id}
|
||||
onClick={() => {
|
||||
if (currentWorkspaceMember?.id === workspaceMember.id) {
|
||||
return;
|
||||
}
|
||||
navigateSettings(SettingsPath.WorkspaceMemberPage, {
|
||||
workspaceMemberId: workspaceMember.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<StyledIconWrapper>
|
||||
<Avatar
|
||||
avatarUrl={workspaceMember.avatarUrl}
|
||||
placeholderColorSeed={workspaceMember.id}
|
||||
placeholder={workspaceMember.name.firstName ?? ''}
|
||||
type="rounded"
|
||||
size="sm"
|
||||
/>
|
||||
</StyledIconWrapper>
|
||||
<StyledTextContainerWithEllipsis
|
||||
id={`hover-text-${workspaceMember.id}`}
|
||||
>
|
||||
{workspaceMember.name.firstName +
|
||||
' ' +
|
||||
workspaceMember.name.lastName}
|
||||
</StyledTextContainerWithEllipsis>
|
||||
<AppTooltip
|
||||
anchorSelect={`#hover-text-${workspaceMember.id}`}
|
||||
content={`${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`}
|
||||
noArrow
|
||||
place="top"
|
||||
positionStrategy="fixed"
|
||||
delay={TooltipDelay.shortDelay}
|
||||
/>
|
||||
</StyledIconWrapper>
|
||||
<StyledTextContainerWithEllipsis>
|
||||
{workspaceInvitation.email}
|
||||
</StyledTextContainerWithEllipsis>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Status
|
||||
color="gray"
|
||||
text={getExpiresAtText(workspaceInvitation.expiresAt)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<StyledButtonContainer>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
handleResendWorkspaceInvitation(
|
||||
workspaceInvitation.id,
|
||||
);
|
||||
}}
|
||||
variant="tertiary"
|
||||
size="medium"
|
||||
Icon={IconReload}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
handleRemoveWorkspaceInvitation(
|
||||
workspaceInvitation.id,
|
||||
);
|
||||
}}
|
||||
variant="tertiary"
|
||||
size="medium"
|
||||
Icon={IconTrash}
|
||||
/>
|
||||
</StyledButtonContainer>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StyledTextContainerWithEllipsis>
|
||||
{workspaceMember.userEmail}
|
||||
</StyledTextContainerWithEllipsis>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<StyledChevronWrapper>
|
||||
{currentWorkspaceMember?.id !==
|
||||
workspaceMember.id && (
|
||||
<IconChevronRight size={theme.icon.size.sm} />
|
||||
)}
|
||||
</StyledChevronWrapper>
|
||||
</TableCell>
|
||||
</StyledClickableTableRow>
|
||||
))
|
||||
) : (
|
||||
<StyledNoMembers>
|
||||
{!searchFilter
|
||||
? t`No members`
|
||||
: t`No members match your search`}
|
||||
</StyledNoMembers>
|
||||
)}
|
||||
</StyledTableRows>
|
||||
</StyledTable>
|
||||
)}
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Manage Members`}
|
||||
description={t`Manage the members of your workspace here`}
|
||||
/>
|
||||
<StyledSearchContainer>
|
||||
<StyledSearchInput
|
||||
instanceId="workspace-members-search"
|
||||
value={searchFilter}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={t`Search a team member...`}
|
||||
fullWidth
|
||||
LeftIcon={IconSearch}
|
||||
sizeVariant="lg"
|
||||
/>
|
||||
</StyledSearchContainer>
|
||||
<StyledTable hasMoreRows={hasNextPage}>
|
||||
<TableRow
|
||||
gridAutoColumns="150px 1fr 40px"
|
||||
mobileGridAutoColumns="100px 1fr 32px"
|
||||
>
|
||||
<TableHeader>
|
||||
<Trans>Name</Trans>
|
||||
</TableHeader>
|
||||
<TableHeader>
|
||||
<Trans>Email</Trans>
|
||||
</TableHeader>
|
||||
<TableHeader align="right"></TableHeader>
|
||||
</TableRow>
|
||||
<StyledTableRows>
|
||||
{optimizedWorkspaceMembers.length > 0 ? (
|
||||
optimizedWorkspaceMembers.map((workspaceMember) => (
|
||||
<StyledClickableTableRow
|
||||
gridAutoColumns="150px 1fr 40px"
|
||||
mobileGridAutoColumns="100px 1fr 32px"
|
||||
key={workspaceMember.id}
|
||||
onClick={() => {
|
||||
if (currentWorkspaceMember?.id === workspaceMember.id) {
|
||||
return;
|
||||
}
|
||||
navigateSettings(SettingsPath.WorkspaceMemberPage, {
|
||||
workspaceMemberId: workspaceMember.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<StyledIconWrapper>
|
||||
<Avatar
|
||||
avatarUrl={workspaceMember.avatarUrl}
|
||||
placeholderColorSeed={workspaceMember.id}
|
||||
placeholder={workspaceMember.name.firstName ?? ''}
|
||||
type="rounded"
|
||||
size="sm"
|
||||
/>
|
||||
</StyledIconWrapper>
|
||||
<StyledTextContainerWithEllipsis
|
||||
id={`hover-text-${workspaceMember.id}`}
|
||||
>
|
||||
{workspaceMember.name.firstName +
|
||||
' ' +
|
||||
workspaceMember.name.lastName}
|
||||
</StyledTextContainerWithEllipsis>
|
||||
<AppTooltip
|
||||
anchorSelect={`#hover-text-${workspaceMember.id}`}
|
||||
content={`${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`}
|
||||
noArrow
|
||||
place="top"
|
||||
positionStrategy="fixed"
|
||||
delay={TooltipDelay.shortDelay}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StyledTextContainerWithEllipsis>
|
||||
{workspaceMember.userEmail}
|
||||
</StyledTextContainerWithEllipsis>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<StyledChevronWrapper>
|
||||
{currentWorkspaceMember?.id !== workspaceMember.id && (
|
||||
<IconChevronRight size={theme.icon.size.sm} />
|
||||
)}
|
||||
</StyledChevronWrapper>
|
||||
</TableCell>
|
||||
</StyledClickableTableRow>
|
||||
))
|
||||
) : (
|
||||
<StyledNoMembers>
|
||||
{!searchFilter
|
||||
? t`No members`
|
||||
: t`No members match your search`}
|
||||
</StyledNoMembers>
|
||||
{hasNextPage && (
|
||||
<TableRow
|
||||
gridAutoColumns="250px 1fr 1fr"
|
||||
mobileGridAutoColumns="100px 1fr 1fr"
|
||||
>
|
||||
<TableCell>
|
||||
<div ref={fetchMoreRef} style={{ height: '1px' }} />
|
||||
</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</StyledTableRows>
|
||||
{hasNextPage && (
|
||||
<TableRow
|
||||
gridAutoColumns="250px 1fr 1fr"
|
||||
mobileGridAutoColumns="100px 1fr 1fr"
|
||||
>
|
||||
<TableCell>
|
||||
<div ref={fetchMoreRef} style={{ height: '1px' }} />
|
||||
</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</StyledTable>
|
||||
</Section>
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
</StyledTable>
|
||||
</Section>
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,10 +8,13 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
|
||||
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
|
||||
import { Separator } from '@/settings/components/Separator';
|
||||
import { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton';
|
||||
import { SettingsOptionCardContentCounter } from '@/settings/components/SettingsOptions/SettingsOptionCardContentCounter';
|
||||
import { Separator } from '@/settings/components/Separator';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { SettingsRoleDefaultRole } from '@/settings/roles/components/SettingsRolesDefaultRole';
|
||||
import { SettingsRolesQueryEffect } from '@/settings/roles/components/SettingsRolesQueryEffect';
|
||||
import { useSettingsAllRoles } from '@/settings/roles/hooks/useSettingsAllRoles';
|
||||
import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard';
|
||||
import { SettingsSecurityAuthBypassOptionsList } from '@/settings/security/components/SettingsSecurityAuthBypassOptionsList';
|
||||
import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList';
|
||||
@@ -20,6 +23,8 @@ import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdent
|
||||
import { ToggleImpersonate } from '@/settings/workspace/components/ToggleImpersonate';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { ApolloError } from '@apollo/client';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
@@ -34,8 +39,6 @@ import {
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Card, Section } from 'twenty-ui/layout';
|
||||
import { useUpdateWorkspaceMutation } from '~/generated-metadata/graphql';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
width: 100%;
|
||||
@@ -140,6 +143,8 @@ export const SettingsSecurity = () => {
|
||||
saveEventLogRetention(value);
|
||||
};
|
||||
|
||||
const roles = useSettingsAllRoles();
|
||||
|
||||
const hasSsoIdentityProviders = SSOIdentitiesProviders.length > 0;
|
||||
const hasDirectAuthEnabled =
|
||||
currentWorkspace?.isGoogleAuthEnabled ||
|
||||
@@ -169,6 +174,7 @@ export const SettingsSecurity = () => {
|
||||
]}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
<SettingsRolesQueryEffect />
|
||||
<StyledMainContent>
|
||||
<StyledSection>
|
||||
<H2Title
|
||||
@@ -204,6 +210,7 @@ export const SettingsSecurity = () => {
|
||||
<SettingsSecurityEditableProfileFields />
|
||||
</StyledContainer>
|
||||
</Section>
|
||||
<SettingsRoleDefaultRole roles={roles} />
|
||||
{shouldShowBypassSection && (
|
||||
<Section>
|
||||
<StyledContainer>
|
||||
|
||||
@@ -91,6 +91,7 @@ export class AppTokenEntity {
|
||||
@Column({ nullable: true, type: 'jsonb' })
|
||||
context: {
|
||||
email?: string;
|
||||
roleId?: string;
|
||||
redirectUri?: string;
|
||||
clientId?: string;
|
||||
codeChallenge?: string;
|
||||
|
||||
@@ -124,6 +124,7 @@ export class AuthService {
|
||||
await this.userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace(
|
||||
user,
|
||||
workspace,
|
||||
invitation.context?.roleId,
|
||||
);
|
||||
|
||||
return;
|
||||
|
||||
@@ -208,6 +208,7 @@ export class SignInUpService {
|
||||
const updatedUser = await this.signInUpOnExistingWorkspace({
|
||||
workspace: invitationValidation.workspace,
|
||||
userData: params.userData,
|
||||
roleId: params.invitation.context?.roleId,
|
||||
});
|
||||
|
||||
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
|
||||
@@ -256,6 +257,7 @@ export class SignInUpService {
|
||||
async signInUpOnExistingWorkspace(
|
||||
params: {
|
||||
workspace: WorkspaceEntity;
|
||||
roleId?: string | null;
|
||||
} & ExistingUserOrPartialUserWithPicture,
|
||||
) {
|
||||
await this.throwIfWorkspaceIsNotReadyForSignInUp(params.workspace, params);
|
||||
@@ -282,6 +284,7 @@ export class SignInUpService {
|
||||
await this.userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace(
|
||||
user,
|
||||
params.workspace,
|
||||
params.roleId,
|
||||
);
|
||||
|
||||
return user;
|
||||
@@ -297,6 +300,7 @@ export class SignInUpService {
|
||||
await this.userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace(
|
||||
user,
|
||||
params.workspace,
|
||||
params.roleId,
|
||||
);
|
||||
|
||||
return user;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { RoleValidationModule } from 'src/engine/metadata-modules/role-validation/role-validation.module';
|
||||
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
||||
@@ -36,6 +37,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
|
||||
WorkspaceEntity,
|
||||
RoleTargetEntity,
|
||||
]),
|
||||
RoleValidationModule,
|
||||
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity]),
|
||||
TypeORMModule,
|
||||
DataSourceModule,
|
||||
|
||||
+7
@@ -26,6 +26,7 @@ import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspac
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { RoleValidationService } from 'src/engine/metadata-modules/role-validation/services/role-validation.service';
|
||||
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
@@ -77,6 +78,12 @@ describe('UserWorkspaceService', () => {
|
||||
findOneOrFail: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RoleValidationService,
|
||||
useValue: {
|
||||
validateRoleAssignableToUsersOrThrow: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DataSourceService,
|
||||
useValue: {
|
||||
|
||||
+53
-27
@@ -35,6 +35,7 @@ import {
|
||||
PermissionsExceptionCode,
|
||||
PermissionsExceptionMessage,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { RoleValidationService } from 'src/engine/metadata-modules/role-validation/services/role-validation.service';
|
||||
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
@@ -51,6 +52,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
|
||||
private readonly userRepository: Repository<UserEntity>,
|
||||
@InjectRepository(RoleTargetEntity)
|
||||
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
|
||||
private readonly roleValidationService: RoleValidationService,
|
||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||
private readonly workspaceDomainsService: WorkspaceDomainsService,
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
@@ -148,47 +150,71 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
|
||||
async addUserToWorkspaceIfUserNotInWorkspace(
|
||||
user: UserEntity,
|
||||
workspace: WorkspaceEntity,
|
||||
roleId?: string | null,
|
||||
) {
|
||||
let userWorkspace = await this.checkUserWorkspaceExists(
|
||||
const existingUserWorkspace = await this.checkUserWorkspaceExists(
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
if (!userWorkspace) {
|
||||
userWorkspace = await this.create({
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
isExistingUser: true,
|
||||
});
|
||||
if (existingUserWorkspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.createWorkspaceMember(workspace.id, user);
|
||||
const resolvedRoleId = await this.resolveRoleIdForNewMember(
|
||||
roleId,
|
||||
workspace,
|
||||
);
|
||||
|
||||
const defaultRoleId = workspace.defaultRoleId;
|
||||
const userWorkspace = await this.create({
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
isExistingUser: true,
|
||||
});
|
||||
|
||||
if (!isDefined(defaultRoleId)) {
|
||||
throw new PermissionsException(
|
||||
PermissionsExceptionMessage.DEFAULT_ROLE_NOT_FOUND,
|
||||
PermissionsExceptionCode.DEFAULT_ROLE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
await this.createWorkspaceMember(workspace.id, user);
|
||||
|
||||
await this.userRoleService.assignRoleToManyUserWorkspace({
|
||||
workspaceId: workspace.id,
|
||||
userWorkspaceIds: [userWorkspace.id],
|
||||
roleId: defaultRoleId,
|
||||
});
|
||||
await this.userRoleService.assignRoleToManyUserWorkspace({
|
||||
workspaceId: workspace.id,
|
||||
userWorkspaceIds: [userWorkspace.id],
|
||||
roleId: resolvedRoleId,
|
||||
});
|
||||
|
||||
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
|
||||
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
|
||||
workspace.id,
|
||||
user.email,
|
||||
);
|
||||
|
||||
await this.onboardingService.setOnboardingCreateProfilePending({
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveRoleIdForNewMember(
|
||||
roleId: string | null | undefined,
|
||||
workspace: WorkspaceEntity,
|
||||
): Promise<string> {
|
||||
if (isDefined(roleId)) {
|
||||
await this.roleValidationService.validateRoleAssignableToUsersOrThrow(
|
||||
roleId,
|
||||
workspace.id,
|
||||
user.email,
|
||||
);
|
||||
|
||||
await this.onboardingService.setOnboardingCreateProfilePending({
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
value: true,
|
||||
});
|
||||
return roleId;
|
||||
}
|
||||
|
||||
const defaultRoleId = workspace.defaultRoleId;
|
||||
|
||||
if (!isDefined(defaultRoleId)) {
|
||||
throw new PermissionsException(
|
||||
PermissionsExceptionMessage.DEFAULT_ROLE_NOT_FOUND,
|
||||
PermissionsExceptionCode.DEFAULT_ROLE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return defaultRoleId;
|
||||
}
|
||||
|
||||
public async getUserCount(workspaceId: string): Promise<number | undefined> {
|
||||
|
||||
+14
-1
@@ -1,6 +1,14 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { ArrayUnique, IsArray, IsEmail } from 'class-validator';
|
||||
import {
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsEmail,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ArgsType()
|
||||
export class SendInvitationsInput {
|
||||
@@ -9,4 +17,9 @@ export class SendInvitationsInput {
|
||||
@IsEmail({}, { each: true })
|
||||
@ArrayUnique()
|
||||
emails: string[];
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
roleId?: string | null;
|
||||
}
|
||||
|
||||
+3
@@ -12,6 +12,9 @@ export class WorkspaceInvitation {
|
||||
@Field({ nullable: false })
|
||||
email: string;
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
roleId?: string | null;
|
||||
|
||||
@Field({ nullable: false })
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
+7
@@ -16,6 +16,7 @@ import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.se
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceInvitationException } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception';
|
||||
import { RoleValidationService } from 'src/engine/metadata-modules/role-validation/services/role-validation.service';
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
@@ -60,6 +61,12 @@ describe('WorkspaceInvitationService', () => {
|
||||
provide: getRepositoryToken(WorkspaceEntity),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: RoleValidationService,
|
||||
useValue: {
|
||||
validateRoleAssignableToUsersOrThrow: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceDomainsService,
|
||||
useValue: {
|
||||
|
||||
+48
-45
@@ -35,6 +35,7 @@ import {
|
||||
WorkspaceInvitationExceptionCode,
|
||||
} from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { RoleValidationService } from 'src/engine/metadata-modules/role-validation/services/role-validation.service';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
@@ -45,6 +46,7 @@ export class WorkspaceInvitationService {
|
||||
private readonly appTokenRepository: Repository<AppTokenEntity>,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
private readonly roleValidationService: RoleValidationService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly onboardingService: OnboardingService,
|
||||
@@ -153,7 +155,11 @@ export class WorkspaceInvitationService {
|
||||
return appTokens.map(castAppTokenToWorkspaceInvitationUtil);
|
||||
}
|
||||
|
||||
async createWorkspaceInvitation(email: string, workspace: WorkspaceEntity) {
|
||||
async createWorkspaceInvitation(
|
||||
email: string,
|
||||
workspace: WorkspaceEntity,
|
||||
roleId?: string,
|
||||
) {
|
||||
const maybeWorkspaceInvitation = await this.getOneWorkspaceInvitation(
|
||||
workspace.id,
|
||||
email.toLowerCase(),
|
||||
@@ -185,7 +191,7 @@ export class WorkspaceInvitationService {
|
||||
);
|
||||
}
|
||||
|
||||
return this.generateInvitationToken(workspace.id, email);
|
||||
return this.generateInvitationToken(workspace.id, email, roleId);
|
||||
}
|
||||
|
||||
async deleteWorkspaceInvitation(appTokenId: string, workspaceId: string) {
|
||||
@@ -238,14 +244,19 @@ export class WorkspaceInvitationService {
|
||||
|
||||
await this.appTokenRepository.delete(appToken.id);
|
||||
|
||||
return this.sendInvitations([appToken.context.email], workspace, sender);
|
||||
return this.sendInvitations(
|
||||
[appToken.context.email],
|
||||
workspace,
|
||||
sender,
|
||||
appToken.context.roleId,
|
||||
);
|
||||
}
|
||||
|
||||
async sendInvitations(
|
||||
emails: string[],
|
||||
workspace: WorkspaceEntity,
|
||||
sender: WorkspaceMemberWorkspaceEntity,
|
||||
usePersonalInvitation = true,
|
||||
roleId?: string,
|
||||
): Promise<SendInvitationsDTO> {
|
||||
if (!workspace?.inviteHash) {
|
||||
return {
|
||||
@@ -255,50 +266,45 @@ export class WorkspaceInvitationService {
|
||||
};
|
||||
}
|
||||
|
||||
if (isDefined(roleId)) {
|
||||
await this.roleValidationService.validateRoleAssignableToUsersOrThrow(
|
||||
roleId,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
await this.throttleInvitationSending(workspace.id, emails);
|
||||
|
||||
const invitationsPr = await Promise.allSettled(
|
||||
const invitationResults = await Promise.allSettled(
|
||||
emails.map(async (email) => {
|
||||
if (usePersonalInvitation) {
|
||||
const appToken = await this.createWorkspaceInvitation(
|
||||
email,
|
||||
workspace,
|
||||
const appToken = await this.createWorkspaceInvitation(
|
||||
email,
|
||||
workspace,
|
||||
roleId,
|
||||
);
|
||||
|
||||
if (!appToken.context?.email) {
|
||||
throw new WorkspaceInvitationException(
|
||||
'Invalid email',
|
||||
WorkspaceInvitationExceptionCode.EMAIL_MISSING,
|
||||
);
|
||||
|
||||
if (!appToken.context?.email) {
|
||||
throw new WorkspaceInvitationException(
|
||||
'Invalid email',
|
||||
WorkspaceInvitationExceptionCode.EMAIL_MISSING,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
isPersonalInvitation: true as const,
|
||||
appToken,
|
||||
email: appToken.context.email,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isPersonalInvitation: false as const,
|
||||
email,
|
||||
};
|
||||
return { appToken, email: appToken.context.email };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const invitation of invitationsPr) {
|
||||
for (const invitation of invitationResults) {
|
||||
if (invitation.status === 'fulfilled') {
|
||||
const link = this.workspaceDomainsService.buildWorkspaceURL({
|
||||
workspace,
|
||||
pathname: getAppPath(AppPath.Invite, {
|
||||
workspaceInviteHash: workspace?.inviteHash,
|
||||
}),
|
||||
searchParams: invitation.value.isPersonalInvitation
|
||||
? {
|
||||
inviteToken: invitation.value.appToken.value,
|
||||
email: invitation.value.email,
|
||||
}
|
||||
: {},
|
||||
searchParams: {
|
||||
inviteToken: invitation.value.appToken.value,
|
||||
email: invitation.value.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDefined(sender.userEmail)) {
|
||||
@@ -360,15 +366,9 @@ export class WorkspaceInvitationService {
|
||||
|
||||
const i18n = this.i18nService.getI18nInstance(sender.locale);
|
||||
|
||||
const result = invitationsPr.reduce<{
|
||||
const result = invitationResults.reduce<{
|
||||
errors: string[];
|
||||
result: ReturnType<
|
||||
typeof this.workspaceInvitationService.createWorkspaceInvitation
|
||||
>['status'] extends 'rejected'
|
||||
? never
|
||||
: ReturnType<
|
||||
typeof this.workspaceInvitationService.appTokenToWorkspaceInvitation
|
||||
>;
|
||||
result: ReturnType<typeof castAppTokenToWorkspaceInvitationUtil>[];
|
||||
}>(
|
||||
(acc, invitation) => {
|
||||
if (invitation.status === 'rejected') {
|
||||
@@ -381,9 +381,7 @@ export class WorkspaceInvitationService {
|
||||
}
|
||||
} else {
|
||||
acc.result.push(
|
||||
invitation.value.isPersonalInvitation
|
||||
? castAppTokenToWorkspaceInvitationUtil(invitation.value.appToken)
|
||||
: { email: invitation.value.email },
|
||||
castAppTokenToWorkspaceInvitationUtil(invitation.value.appToken),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -398,7 +396,11 @@ export class WorkspaceInvitationService {
|
||||
};
|
||||
}
|
||||
|
||||
async generateInvitationToken(workspaceId: string, email: string) {
|
||||
async generateInvitationToken(
|
||||
workspaceId: string,
|
||||
email: string,
|
||||
roleId?: string,
|
||||
) {
|
||||
const expiresIn = this.twentyConfigService.get(
|
||||
'INVITATION_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
@@ -419,6 +421,7 @@ export class WorkspaceInvitationService {
|
||||
value: crypto.randomBytes(32).toString('hex'),
|
||||
context: {
|
||||
email,
|
||||
...(isDefined(roleId) ? { roleId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+1
@@ -55,6 +55,7 @@ describe('castAppTokenToWorkspaceInvitation', () => {
|
||||
expect(invitation).toEqual({
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
roleId: null,
|
||||
expiresAt: appToken.expiresAt,
|
||||
});
|
||||
});
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ export const castAppTokenToWorkspaceInvitationUtil = (
|
||||
return {
|
||||
id: appToken.id,
|
||||
email: appToken.context.email,
|
||||
roleId: appToken.context.roleId ?? null,
|
||||
expiresAt: appToken.expiresAt,
|
||||
};
|
||||
};
|
||||
|
||||
+2
@@ -13,6 +13,7 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in
|
||||
import { WorkspaceInvitationResolver } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.resolver';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { RoleValidationModule } from 'src/engine/metadata-modules/role-validation/role-validation.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -22,6 +23,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
UserWorkspaceEntity,
|
||||
WorkspaceEntity,
|
||||
]),
|
||||
RoleValidationModule,
|
||||
FileModule,
|
||||
OnboardingModule,
|
||||
PermissionsModule,
|
||||
|
||||
+1
@@ -122,6 +122,7 @@ export class WorkspaceInvitationResolver {
|
||||
sendInviteLinkInput.emails,
|
||||
workspace,
|
||||
workspaceMember,
|
||||
sendInviteLinkInput.roleId ?? undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { RoleValidationService } from 'src/engine/metadata-modules/role-validation/services/role-validation.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([RoleEntity])],
|
||||
providers: [RoleValidationService],
|
||||
exports: [RoleValidationService],
|
||||
})
|
||||
export class RoleValidationModule {}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
PermissionsException,
|
||||
PermissionsExceptionCode,
|
||||
PermissionsExceptionMessage,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
|
||||
@Injectable()
|
||||
export class RoleValidationService {
|
||||
constructor(
|
||||
@InjectRepository(RoleEntity)
|
||||
private readonly roleRepository: Repository<RoleEntity>,
|
||||
) {}
|
||||
|
||||
async validateRoleAssignableToUsersOrThrow(
|
||||
roleId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const role = await this.roleRepository.findOne({
|
||||
where: {
|
||||
id: roleId,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new PermissionsException(
|
||||
PermissionsExceptionMessage.ROLE_NOT_FOUND,
|
||||
PermissionsExceptionCode.ROLE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (!role.canBeAssignedToUsers) {
|
||||
throw new PermissionsException(
|
||||
PermissionsExceptionMessage.ROLE_CANNOT_BE_ASSIGNED_TO_USERS,
|
||||
PermissionsExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_USERS,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -276,7 +276,10 @@ describe('Granular settings permissions', () => {
|
||||
const inviteWorkspaceMemberQuery = {
|
||||
query: `
|
||||
mutation SendWorkspaceInvitation {
|
||||
sendInvitations(emails: ["test@example.com"]) {
|
||||
sendInvitations(
|
||||
emails: ["test@example.com"],
|
||||
roleId: "${originalMemberRoleId}"
|
||||
) {
|
||||
success
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user