snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { isDefined } from 'twenty-sdk/utils';
import { Avatar } from 'twenty-ui/data-display';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { InlineWorkspaceMemberPickerPanel } from 'src/front-components/components/InlineWorkspaceMemberPickerPanel';
import { type WorkspaceMemberOption } from 'src/front-components/types/workspace-member-option.type';
import { getMemberDisplayName } from 'src/front-components/utils/get-member-display-name.util';
const StyledContainer = styled.div`
flex: 1;
min-width: 0;
position: relative;
`;
const StyledSelectTrigger = styled.button`
align-items: center;
background-color: ${() => themeCssVariables.background.transparent.lighter};
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.md};
box-sizing: border-box;
cursor: pointer;
display: grid;
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
gap: ${() => themeCssVariables.spacing[1]};
grid-template-columns: 1fr auto;
height: ${() => themeCssVariables.spacing[6]};
padding: 0 ${() => themeCssVariables.spacing[2]};
text-align: left;
width: 100%;
&:hover:enabled {
background-color: ${() => themeCssVariables.background.transparent.light};
}
&:disabled {
cursor: not-allowed;
}
`;
const StyledSelectedValue = styled.div`
align-items: center;
display: flex;
gap: ${() => themeCssVariables.spacing[1]};
min-width: 0;
`;
const StyledValueName = styled.div`
color: ${() => themeCssVariables.font.color.primary};
min-width: 0;
`;
const StyledPlaceholder = styled.div`
color: ${() => themeCssVariables.font.color.tertiary};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledChevron = styled.div`
border-bottom: 1px solid ${() => themeCssVariables.font.color.tertiary};
border-right: 1px solid ${() => themeCssVariables.font.color.tertiary};
height: 5px;
margin-top: -3px;
transform: rotate(45deg);
width: 5px;
`;
type InlineWorkspaceMemberPickerProps = {
selectedMember: WorkspaceMemberOption | null;
onSelect: (member: WorkspaceMemberOption) => void;
disabled?: boolean;
};
export const InlineWorkspaceMemberPicker = ({
selectedMember,
onSelect,
disabled,
}: InlineWorkspaceMemberPickerProps) => {
const [isOpen, setIsOpen] = useState(false);
return (
<StyledContainer>
<StyledSelectTrigger
type="button"
disabled={disabled}
onClick={() => setIsOpen(true)}
aria-label="Select the workspace member"
aria-haspopup="listbox"
aria-expanded={isOpen}
>
{isDefined(selectedMember) ? (
<StyledSelectedValue>
<Avatar
placeholder={getMemberDisplayName(selectedMember)}
placeholderColorSeed={selectedMember.id}
type="rounded"
size="sm"
/>
<StyledValueName>
<OverflowingTextWithTooltip
text={getMemberDisplayName(selectedMember)}
/>
</StyledValueName>
</StyledSelectedValue>
) : (
<StyledPlaceholder>Select member</StyledPlaceholder>
)}
<StyledChevron />
</StyledSelectTrigger>
{isOpen && (
<InlineWorkspaceMemberPickerPanel
onSelect={(member) => {
setIsOpen(false);
onSelect(member);
}}
onClose={() => setIsOpen(false)}
/>
)}
</StyledContainer>
);
};

View File

@@ -0,0 +1,141 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { MenuItem, MenuItemAvatar } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useWorkspaceMemberSearch } from 'src/front-components/hooks/use-workspace-member-search';
import { type WorkspaceMemberOption } from 'src/front-components/types/workspace-member-option.type';
import { getMemberDisplayName } from 'src/front-components/utils/get-member-display-name.util';
const StyledBackdrop = styled.div`
inset: 0;
position: fixed;
z-index: 1;
`;
const StyledDropdownPanel = styled.div`
background: ${() => themeCssVariables.background.primary};
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.md};
box-shadow: ${() => themeCssVariables.boxShadow.light};
box-sizing: border-box;
left: 0;
margin-top: ${() => themeCssVariables.spacing[1]};
min-width: 240px;
position: absolute;
top: 100%;
width: 100%;
z-index: 2;
`;
const StyledSearchInput = styled.input`
background: transparent;
border: none;
border-bottom: 1px solid ${() => themeCssVariables.border.color.light};
box-sizing: border-box;
color: ${() => themeCssVariables.font.color.primary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
height: ${() => themeCssVariables.spacing[8]};
outline: none;
padding: 0 ${() => themeCssVariables.spacing[2]};
width: 100%;
&::placeholder {
color: ${() => themeCssVariables.font.color.light};
}
`;
const StyledOptions = styled.div`
max-height: 240px;
overflow-y: auto;
padding: ${() => themeCssVariables.spacing[1]};
`;
const getEmptyStateText = ({
isSearching,
searchErrorMessage,
}: {
isSearching: boolean;
searchErrorMessage: string | undefined;
}): string => {
if (isSearching) {
return 'Searching…';
}
if (isNonEmptyString(searchErrorMessage)) {
return searchErrorMessage;
}
return 'No results';
};
type InlineWorkspaceMemberPickerPanelProps = {
onSelect: (member: WorkspaceMemberOption) => void;
onClose: () => void;
};
export const InlineWorkspaceMemberPickerPanel = ({
onSelect,
onClose,
}: InlineWorkspaceMemberPickerPanelProps) => {
const [searchTerm, setSearchTerm] = useState('');
const { options, isSearching, searchErrorMessage } = useWorkspaceMemberSearch(
{ searchTerm, shouldListWithoutSearchTerm: true },
);
return (
<>
<StyledBackdrop onClick={onClose} />
<StyledDropdownPanel>
<StyledSearchInput
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (event.key === 'Enter') {
event.preventDefault();
if (options.length > 0) {
onSelect(options[0]);
}
}
}}
placeholder="Search members"
aria-label="Search workspace members"
autoFocus
/>
<StyledOptions role="listbox" aria-label="Workspace members">
{options.map((member) => (
<div key={member.id} role="option" aria-selected={false}>
<MenuItemAvatar
avatar={{
type: 'rounded',
size: 'md',
placeholder: getMemberDisplayName(member),
placeholderColorSeed: member.id,
}}
text={getMemberDisplayName(member)}
contextualText={member.userEmail ?? undefined}
onClick={() => onSelect(member)}
/>
</div>
))}
{options.length === 0 && (
<MenuItem
disabled
text={getEmptyStateText({ isSearching, searchErrorMessage })}
/>
)}
</StyledOptions>
</StyledDropdownPanel>
</>
);
};

View File

@@ -0,0 +1,33 @@
import { isNonEmptyString } from '@sniptt/guards';
import { SlackPickedEntityButton } from 'src/front-components/components/SlackPickedEntityButton';
import { type SlackResolvedUser } from 'src/logic-functions/types/slack-resolved-user.type';
type ResolvedSlackUserFieldProps = {
resolvedUser: SlackResolvedUser;
onChangeRequest: () => void;
disabled?: boolean;
};
const buildFallbackMeta = (resolvedUser: SlackResolvedUser): string =>
isNonEmptyString(resolvedUser.slackTeamId)
? `Slack user ${resolvedUser.slackUserId} · Team ${resolvedUser.slackTeamId}`
: `Slack user ${resolvedUser.slackUserId}`;
export const ResolvedSlackUserField = ({
resolvedUser,
onChangeRequest,
disabled,
}: ResolvedSlackUserFieldProps) => (
<SlackPickedEntityButton
name={resolvedUser.displayName ?? resolvedUser.slackUserId}
meta={
isNonEmptyString(resolvedUser.email)
? resolvedUser.email
: buildFallbackMeta(resolvedUser)
}
changeLabel="Change the Slack user"
onChangeRequest={onChangeRequest}
disabled={disabled}
/>
);

View File

@@ -0,0 +1,153 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SlackUserLinkTextInput } from 'src/front-components/components/SlackUserLinkTextInput';
const StyledContainer = styled.div`
position: relative;
width: 100%;
`;
const StyledDropdown = styled.div`
background: ${() => themeCssVariables.background.primary};
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.sm};
box-shadow: ${() => themeCssVariables.boxShadow.light};
box-sizing: border-box;
left: 0;
margin-top: ${() => themeCssVariables.spacing[1]};
max-height: 240px;
overflow-y: auto;
position: absolute;
right: 0;
top: 100%;
z-index: 1;
`;
const StyledOption = styled.button`
background: transparent;
border: none;
cursor: pointer;
display: flex;
flex-direction: column;
gap: ${() => themeCssVariables.spacing[1]};
padding: ${() => themeCssVariables.spacing[2]};
text-align: left;
width: 100%;
&:hover {
background: ${() => themeCssVariables.background.secondary};
}
`;
const StyledOptionName = styled.span`
color: ${() => themeCssVariables.font.color.primary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
`;
const StyledOptionMeta = styled.span`
color: ${() => themeCssVariables.font.color.tertiary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.xs};
`;
const StyledEmptyState = styled.div`
color: ${() => themeCssVariables.font.color.tertiary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
padding: ${() => themeCssVariables.spacing[2]};
`;
type SearchDropdownPickerProps<TOption> = {
searchTerm: string;
onSearchTermChange: (searchTerm: string) => void;
options: TOption[];
isSearching: boolean;
onSelect: (option: TOption) => void;
getOptionKey: (option: TOption) => string;
getOptionName: (option: TOption) => string;
getOptionMeta: (option: TOption) => string | undefined;
searchLabel: string;
emptyText: string;
disabled?: boolean;
autoFocus?: boolean;
};
export const SearchDropdownPicker = <TOption,>({
searchTerm,
onSearchTermChange,
options,
isSearching,
onSelect,
getOptionKey,
getOptionName,
getOptionMeta,
searchLabel,
emptyText,
disabled,
autoFocus,
}: SearchDropdownPickerProps<TOption>) => {
const [isFocused, setIsFocused] = useState(false);
const hasSearchTerm = isNonEmptyString(searchTerm.trim());
const isDropdownOpen = isFocused && hasSearchTerm;
const handleSelect = (option: TOption) => {
onSearchTermChange('');
onSelect(option);
};
return (
<StyledContainer>
<SlackUserLinkTextInput
value={searchTerm}
onChange={(event) => onSearchTermChange(event.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onKeyDown={(event) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
if (options.length > 0) {
handleSelect(options[0]);
}
}}
placeholder={searchLabel}
disabled={disabled}
autoFocus={autoFocus}
aria-label={searchLabel}
/>
{isDropdownOpen && (
<StyledDropdown onMouseDown={(event) => event.preventDefault()}>
{options.map((option) => {
const meta = getOptionMeta(option);
return (
<StyledOption
key={getOptionKey(option)}
type="button"
onMouseDown={() => handleSelect(option)}
>
<StyledOptionName>{getOptionName(option)}</StyledOptionName>
{isNonEmptyString(meta) && (
<StyledOptionMeta>{meta}</StyledOptionMeta>
)}
</StyledOption>
);
})}
{options.length === 0 && (
<StyledEmptyState>
{isSearching ? 'Searching…' : emptyText}
</StyledEmptyState>
)}
</StyledDropdown>
)}
</StyledContainer>
);
};

View File

@@ -0,0 +1,171 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useId } from 'react';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import { IconLock } from 'twenty-ui/icon';
import { Section } from 'twenty-ui/layout';
import { Card } from 'twenty-ui/surfaces';
import { THEME_COMMON } from 'twenty-ui/theme';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { H2Title } from 'twenty-ui/typography';
import { Toggle } from 'src/front-components/components/Toggle';
import { useSlackAccessMode } from 'src/front-components/hooks/use-slack-access-mode';
import { SLACK_ACCESS_MODE } from 'src/logic-functions/constants/slack-access-mode';
const StyledCardContent = styled.div<{ $disabled: boolean }>`
align-items: center;
background-color: ${() => themeCssVariables.background.secondary};
cursor: ${({ $disabled }) => ($disabled ? 'default' : 'pointer')};
display: flex;
gap: ${() => themeCssVariables.spacing[3]};
padding: ${() => themeCssVariables.spacing[4]};
pointer-events: ${({ $disabled }) => ($disabled ? 'none' : 'auto')};
position: relative;
&:hover {
background: ${() => themeCssVariables.background.transparent.lighter};
}
`;
const StyledIcon = styled.div`
align-items: center;
background-color: ${() => themeCssVariables.background.primary};
border: 2px solid ${() => themeCssVariables.border.color.light};
border-radius: ${() => themeCssVariables.border.radius.sm};
box-sizing: border-box;
color: ${() => themeCssVariables.font.color.secondary};
display: flex;
height: ${() => themeCssVariables.spacing[8]};
justify-content: center;
min-width: ${() => themeCssVariables.spacing[8]};
width: ${() => themeCssVariables.spacing[8]};
`;
const StyledTextContainer = styled.div`
flex: 1;
min-width: 0;
`;
const StyledTitle = styled.div`
color: ${() => themeCssVariables.font.color.primary};
font-family: ${() => themeCssVariables.font.family};
font-weight: ${() => themeCssVariables.font.weight.medium};
margin-bottom: ${() => themeCssVariables.spacing[1]};
`;
const StyledDescription = styled.div`
color: ${() => themeCssVariables.font.color.secondary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
line-height: ${() => themeCssVariables.text.lineHeight.lg};
overflow-wrap: break-word;
`;
const StyledCover = styled.span`
cursor: pointer;
inset: 0;
position: absolute;
`;
const StyledToggleContainer = styled.span`
align-items: center;
display: flex;
flex-shrink: 0;
margin-left: auto;
`;
const RESTRICTED_DESCRIPTION =
'Only Slack accounts linked to a workspace member can use it.';
const OPEN_DESCRIPTION =
'Anyone who can mention the assistant in Slack can use it.';
const UNREADABLE_DESCRIPTION =
'The current setting could not be loaded. Reload to try again.';
const getDescription = ({
hasAccessModeError,
isRestricted,
}: {
hasAccessModeError: boolean;
isRestricted: boolean;
}): string => {
if (hasAccessModeError) {
return UNREADABLE_DESCRIPTION;
}
return isRestricted ? RESTRICTED_DESCRIPTION : OPEN_DESCRIPTION;
};
type SlackAccessModeSectionProps = {
canManage: boolean;
};
export const SlackAccessModeSection = ({
canManage,
}: SlackAccessModeSectionProps) => {
const toggleId = useId();
const {
accessMode,
hasAccessModeError,
isAccessModeLoading,
isSavingAccessMode,
saveAccessMode,
} = useSlackAccessMode();
const isRestricted = accessMode === SLACK_ACCESS_MODE.ONLY_LINKED_MEMBERS;
const isDisabled =
!canManage ||
hasAccessModeError ||
isAccessModeLoading ||
isSavingAccessMode;
const handleToggle = async (value: boolean) => {
const result = await saveAccessMode(
value ? SLACK_ACCESS_MODE.ONLY_LINKED_MEMBERS : SLACK_ACCESS_MODE.ANYONE,
);
enqueueSnackbar({
message: isNonEmptyString(result.error) ? result.error : result.message,
variant: result.success ? 'success' : 'error',
});
};
const description = getDescription({ hasAccessModeError, isRestricted });
return (
<Section>
<H2Title
title="Access"
description="Choose who the assistant answers in Slack."
/>
<Card rounded>
<StyledCardContent $disabled={isDisabled}>
<StyledIcon>
<IconLock size={THEME_COMMON.icon.size.md} />
</StyledIcon>
<StyledTextContainer>
<StyledTitle>
<label htmlFor={toggleId}>
Restrict to linked members
<StyledCover />
</label>
</StyledTitle>
<StyledDescription>{description}</StyledDescription>
</StyledTextContainer>
<StyledToggleContainer>
<Toggle
id={toggleId}
checked={isRestricted}
onChange={handleToggle}
disabled={isDisabled}
ariaLabel="Restrict the assistant to linked members"
/>
</StyledToggleContainer>
</StyledCardContent>
</Card>
</Section>
);
};

View File

@@ -0,0 +1,58 @@
import { SlackUserLinkFormField } from 'src/front-components/components/SlackUserLinkFormField';
import { SlackUserLinkFormHint } from 'src/front-components/components/SlackUserLinkFormHint';
import { SlackUserLinkTextInput } from 'src/front-components/components/SlackUserLinkTextInput';
type SlackConnectUserIdFieldsProps = {
slackUserId: string;
slackTeamId: string;
onSlackUserIdChange: (slackUserId: string) => void;
onSlackTeamIdChange: (slackTeamId: string) => void;
disabled?: boolean;
};
export const SlackConnectUserIdFields = ({
slackUserId,
slackTeamId,
onSlackUserIdChange,
onSlackTeamIdChange,
disabled,
}: SlackConnectUserIdFieldsProps) => (
<>
<SlackUserLinkFormField
label="Slack user ID"
htmlFor="slack-user-id"
hint={
<SlackUserLinkFormHint>
Use this for a Slack Connect user from another workspace, who will not
appear in the search above.
</SlackUserLinkFormHint>
}
>
<SlackUserLinkTextInput
id="slack-user-id"
value={slackUserId}
onChange={(event) => onSlackUserIdChange(event.target.value)}
placeholder="U0123456789"
disabled={disabled}
/>
</SlackUserLinkFormField>
<SlackUserLinkFormField
label="Slack team ID (optional)"
htmlFor="slack-team-id"
hint={
<SlackUserLinkFormHint>
Defaults to the installed Slack workspace. Set it for a Slack Connect
user, using the team ID their messages carry.
</SlackUserLinkFormHint>
}
>
<SlackUserLinkTextInput
id="slack-team-id"
value={slackTeamId}
onChange={(event) => onSlackTeamIdChange(event.target.value)}
placeholder="T0123456789"
disabled={disabled}
/>
</SlackUserLinkFormField>
</>
);

View File

@@ -0,0 +1,72 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledPickedEntity = styled.button`
align-items: center;
background: ${() => themeCssVariables.background.secondary};
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.sm};
box-sizing: border-box;
cursor: pointer;
display: flex;
gap: ${() => themeCssVariables.spacing[2]};
padding: ${() => themeCssVariables.spacing[2]};
text-align: left;
width: 100%;
&:hover:enabled {
border-color: ${() => themeCssVariables.color.blue};
}
&:disabled {
cursor: default;
}
`;
const StyledDetails = styled.div`
display: flex;
flex-direction: column;
gap: ${() => themeCssVariables.spacing[1]};
min-width: 0;
`;
const StyledName = styled.span`
color: ${() => themeCssVariables.font.color.primary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
`;
const StyledMeta = styled.span`
color: ${() => themeCssVariables.font.color.tertiary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.xs};
`;
type SlackPickedEntityButtonProps = {
name: string;
meta?: string;
changeLabel: string;
onChangeRequest: () => void;
disabled?: boolean;
};
export const SlackPickedEntityButton = ({
name,
meta,
changeLabel,
onChangeRequest,
disabled,
}: SlackPickedEntityButtonProps) => (
<StyledPickedEntity
type="button"
onClick={onChangeRequest}
disabled={disabled}
aria-label={changeLabel}
>
<StyledDetails>
<StyledName>{name}</StyledName>
{isNonEmptyString(meta) && <StyledMeta>{meta}</StyledMeta>}
</StyledDetails>
</StyledPickedEntity>
);

View File

@@ -0,0 +1,51 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const SlackTable = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
export const SlackTableRow = styled.div<{ gridTemplateColumns: string }>`
align-items: center;
border-radius: ${() => themeCssVariables.border.radius.md};
display: grid;
grid-template-columns: ${({ gridTemplateColumns }) => gridTemplateColumns};
width: 100%;
`;
export const SlackTableHeader = styled.div<{ align?: 'left' | 'right' }>`
align-items: center;
border-bottom: 1px solid ${() => themeCssVariables.border.color.light};
color: ${() => themeCssVariables.font.color.tertiary};
display: flex;
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
font-weight: ${() => themeCssVariables.font.weight.medium};
height: ${() => themeCssVariables.spacing[8]};
justify-content: ${({ align }) =>
align === 'right' ? 'flex-end' : 'flex-start'};
padding: 0 ${() => themeCssVariables.spacing[2]};
`;
export const SlackTableCell = styled.div<{ align?: 'left' | 'right' }>`
align-items: center;
color: ${() => themeCssVariables.font.color.secondary};
display: flex;
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
gap: ${() => themeCssVariables.spacing[2]};
justify-content: ${({ align }) =>
align === 'right' ? 'flex-end' : 'flex-start'};
min-height: ${() => themeCssVariables.spacing[8]};
min-width: 0;
padding: 0 ${() => themeCssVariables.spacing[2]};
`;
export const SlackTableBody = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
padding: ${() => themeCssVariables.spacing[2]} 0;
`;

View File

@@ -0,0 +1,249 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { isDefined } from 'twenty-sdk/utils';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { H2Title } from 'twenty-ui/typography';
import { ResolvedSlackUserField } from 'src/front-components/components/ResolvedSlackUserField';
import { SlackConnectUserIdFields } from 'src/front-components/components/SlackConnectUserIdFields';
import { SlackUserLinkFormField } from 'src/front-components/components/SlackUserLinkFormField';
import { SlackUserLinkFormHint } from 'src/front-components/components/SlackUserLinkFormHint';
import { SlackUserLinkTextInput } from 'src/front-components/components/SlackUserLinkTextInput';
import { SlackUserPicker } from 'src/front-components/components/SlackUserPicker';
import { WorkspaceMemberPicker } from 'src/front-components/components/WorkspaceMemberPicker';
import { useAutoResolveSlackUser } from 'src/front-components/hooks/use-auto-resolve-slack-user';
import { useSetSlackUserLink } from 'src/front-components/hooks/use-set-slack-user-link';
import { type SlackUserLinkRecord } from 'src/front-components/types/slack-user-link-record.type';
import { type WorkspaceMemberOption } from 'src/front-components/types/workspace-member-option.type';
import { buildSlackUserLinkSaveNote } from 'src/front-components/utils/build-slack-user-link-save-note.util';
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: ${() => themeCssVariables.spacing[4]};
`;
const StyledResolveError = styled.span`
color: ${() => themeCssVariables.color.red};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.xs};
`;
const StyledDisclosure = styled.div`
align-self: flex-start;
`;
const StyledActions = styled.div`
display: flex;
gap: ${() => themeCssVariables.spacing[2]};
`;
type SlackUserLinkFormProps = {
existingLinks: SlackUserLinkRecord[];
onLinkSaved: () => void;
};
export const SlackUserLinkForm = ({
existingLinks,
onLinkSaved,
}: SlackUserLinkFormProps) => {
const [selectedMember, setSelectedMember] =
useState<WorkspaceMemberOption | null>(null);
const [name, setName] = useState('');
const [slackUserId, setSlackUserId] = useState('');
const [slackTeamId, setSlackTeamId] = useState('');
const [isConnectUser, setIsConnectUser] = useState(false);
const [isSlackSearchReopening, setIsSlackSearchReopening] = useState(false);
const {
resolvedUser,
resolveError,
isResolving,
onIdentityChange,
resolveNow,
selectResolvedUser,
clearResolution,
} = useAutoResolveSlackUser();
const { setSlackUserLink, isSubmitting } = useSetSlackUserLink();
const canSubmit =
isDefined(selectedMember) && isDefined(resolvedUser) && !isSubmitting;
const existingLink = !isDefined(resolvedUser)
? undefined
: existingLinks.find(
(link) =>
link.slackUserId === resolvedUser.slackUserId &&
(!isNonEmptyString(link.slackTeamId) ||
!isNonEmptyString(resolvedUser.slackTeamId) ||
link.slackTeamId === resolvedUser.slackTeamId),
);
const resetForm = () => {
setSelectedMember(null);
setName('');
setSlackUserId('');
setSlackTeamId('');
setIsConnectUser(false);
setIsSlackSearchReopening(false);
clearResolution();
};
const handleSubmit = async () => {
if (!isDefined(selectedMember) || !isDefined(resolvedUser)) {
return;
}
const result = await setSlackUserLink({
workspaceMemberId: selectedMember.id,
slackUserId: resolvedUser.slackUserId,
slackTeamId: isNonEmptyString(resolvedUser.slackTeamId)
? resolvedUser.slackTeamId
: undefined,
name: isNonEmptyString(name.trim())
? name.trim()
: resolvedUser.displayName,
});
enqueueSnackbar({
message: isNonEmptyString(result.error) ? result.error : result.message,
variant: result.success ? 'success' : 'error',
});
if (result.success) {
resetForm();
onLinkSaved();
}
};
const handleFormSubmit = () => {
if (!isDefined(resolvedUser)) {
resolveNow({ slackUserId, slackTeamId });
return;
}
if (canSubmit) {
handleSubmit();
}
};
return (
<Section>
<H2Title
title="Link a Slack user"
description="Pick a workspace member and the Slack account whose messages should act with that member's permissions. A link whose Slack email matches the member activates immediately; other in-workspace links ask the Slack user to approve first."
/>
<StyledForm
onSubmit={(event) => {
event.preventDefault();
handleFormSubmit();
}}
>
<SlackUserLinkFormField label="Workspace member">
<WorkspaceMemberPicker
selectedMember={selectedMember}
onSelect={setSelectedMember}
onClear={() => setSelectedMember(null)}
disabled={isSubmitting}
/>
</SlackUserLinkFormField>
{isDefined(resolvedUser) ? (
<SlackUserLinkFormField label="Slack user">
<ResolvedSlackUserField
resolvedUser={resolvedUser}
onChangeRequest={() => {
setIsSlackSearchReopening(true);
setName('');
clearResolution();
}}
disabled={isSubmitting}
/>
</SlackUserLinkFormField>
) : (
<>
<SlackUserLinkFormField label="Slack user">
<SlackUserPicker
onSelect={selectResolvedUser}
disabled={isSubmitting}
autoFocus={isSlackSearchReopening}
/>
</SlackUserLinkFormField>
{isConnectUser ? (
<SlackConnectUserIdFields
slackUserId={slackUserId}
slackTeamId={slackTeamId}
onSlackUserIdChange={(nextSlackUserId) => {
setSlackUserId(nextSlackUserId);
onIdentityChange({
slackUserId: nextSlackUserId,
slackTeamId,
});
}}
onSlackTeamIdChange={(nextSlackTeamId) => {
setSlackTeamId(nextSlackTeamId);
onIdentityChange({
slackUserId,
slackTeamId: nextSlackTeamId,
});
}}
disabled={isSubmitting}
/>
) : (
<StyledDisclosure>
<Button
type="button"
title="Guest or Slack Connect user? Link by Slack ID instead"
size="small"
variant="tertiary"
onClick={() => setIsConnectUser(true)}
/>
</StyledDisclosure>
)}
{isResolving ? (
<SlackUserLinkFormHint>
Finding the Slack user…
</SlackUserLinkFormHint>
) : isDefined(resolveError) ? (
<StyledResolveError>{resolveError}</StyledResolveError>
) : null}
</>
)}
<SlackUserLinkFormField
label="Display name (optional)"
htmlFor="slack-display-name"
>
<SlackUserLinkTextInput
id="slack-display-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={resolvedUser?.displayName ?? 'Ada Lovelace'}
disabled={isSubmitting}
/>
</SlackUserLinkFormField>
{isDefined(resolvedUser) && (
<SlackUserLinkFormHint>
{buildSlackUserLinkSaveNote({
resolvedUser,
selectedMember,
existingLink,
})}
</SlackUserLinkFormHint>
)}
<StyledActions>
<Button
type="button"
title={isSubmitting ? 'Saving…' : 'Save link'}
variant="primary"
accent="blue"
disabled={!canSubmit}
onClick={handleSubmit}
/>
</StyledActions>
</StyledForm>
</Section>
);
};

View File

@@ -0,0 +1,36 @@
import styled from '@emotion/styled';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledField = styled.div`
display: flex;
flex-direction: column;
gap: ${() => themeCssVariables.spacing[1]};
`;
const StyledLabel = styled.label`
color: ${() => themeCssVariables.font.color.secondary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
font-weight: ${() => themeCssVariables.font.weight.medium};
`;
type SlackUserLinkFormFieldProps = {
label: string;
htmlFor?: string;
children: ReactNode;
hint?: ReactNode;
};
export const SlackUserLinkFormField = ({
label,
htmlFor,
children,
hint,
}: SlackUserLinkFormFieldProps) => (
<StyledField>
<StyledLabel htmlFor={htmlFor}>{label}</StyledLabel>
{children}
{hint}
</StyledField>
);

View File

@@ -0,0 +1,8 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const SlackUserLinkFormHint = styled.span`
color: ${() => themeCssVariables.font.color.tertiary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.xs};
`;

View File

@@ -0,0 +1,34 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const SlackUserLinkTextInput = styled.input`
background-color: ${() => themeCssVariables.background.transparent.lighter};
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.md};
box-sizing: border-box;
color: ${() => themeCssVariables.font.color.primary};
display: flex;
flex-grow: 1;
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.md};
font-weight: ${() => themeCssVariables.font.weight.regular};
height: 32px;
outline: none;
padding: ${() => themeCssVariables.spacing[2]};
text-overflow: ellipsis;
width: 100%;
&::placeholder {
color: ${() => themeCssVariables.font.color.light};
font-family: ${() => themeCssVariables.font.family};
font-weight: ${() => themeCssVariables.font.weight.medium};
}
&:disabled {
color: ${() => themeCssVariables.font.color.tertiary};
}
&:focus {
border-color: ${() => themeCssVariables.color.blue};
}
`;

View File

@@ -0,0 +1,284 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-sdk/utils';
import { Avatar, Tag } from 'twenty-ui/data-display';
import { Button } from 'twenty-ui/input';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
SlackTable,
SlackTableBody,
SlackTableCell,
SlackTableHeader,
SlackTableRow,
} from 'src/front-components/components/SlackSettingsTable';
import { SLACK_USER_LINK_CONSENT_STATE } from 'src/logic-functions/constants/slack-user-link-consent-state';
import { SLACK_USER_LINK_SOURCE } from 'src/logic-functions/constants/slack-user-link-source';
import { type SlackUserLinkConsentState } from 'src/logic-functions/types/slack-user-link-consent-state.type';
import { isSlackUserLinkConsentState } from 'src/logic-functions/utils/is-slack-user-link-consent-state';
import { type SlackUserLinkRecord } from 'src/front-components/types/slack-user-link-record.type';
const LINKS_GRID_TEMPLATE_COLUMNS = 'minmax(0, 2fr) minmax(0, 2fr) 320px 156px';
const REMOVAL_CONFIRM_TIMEOUT_MS = 4000;
const StyledIdentity = styled.div`
align-items: center;
display: flex;
gap: ${() => themeCssVariables.spacing[2]};
min-width: 0;
`;
const StyledName = styled.div`
color: ${() => themeCssVariables.font.color.primary};
min-width: 0;
`;
const StyledDetails = styled.div`
display: flex;
flex-direction: column;
min-width: 0;
padding: ${() => themeCssVariables.spacing[1]} 0;
`;
const StyledMeta = styled.div`
color: ${() => themeCssVariables.font.color.tertiary};
font-size: ${() => themeCssVariables.font.size.xs};
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledEmptyState = styled.div`
color: ${() => themeCssVariables.font.color.tertiary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
padding: ${() => themeCssVariables.spacing[2]};
`;
type TagColor = 'blue' | 'green' | 'orange' | 'red' | 'gray';
const getSourceLabel = (source: string | null): string =>
source === SLACK_USER_LINK_SOURCE.MANUAL
? 'Set manually'
: 'Matched on email';
const getSourceColor = (source: string | null): TagColor =>
source === SLACK_USER_LINK_SOURCE.MANUAL ? 'blue' : 'green';
// An absent state is a pre-consent link and still lends access; a state this
// version cannot interpret is not the same thing and is left unlabelled.
const toDisplayedConsentState = (
consentState: string | null,
): SlackUserLinkConsentState | undefined => {
if (!isNonEmptyString(consentState)) {
return SLACK_USER_LINK_CONSENT_STATE.ACTIVE;
}
return isSlackUserLinkConsentState(consentState) ? consentState : undefined;
};
const CONSENT_LABELS: Record<SlackUserLinkConsentState, string> = {
[SLACK_USER_LINK_CONSENT_STATE.ACTIVE]: 'Active',
[SLACK_USER_LINK_CONSENT_STATE.PENDING]: 'Awaiting consent',
[SLACK_USER_LINK_CONSENT_STATE.DECLINED]: 'Declined',
[SLACK_USER_LINK_CONSENT_STATE.ADMIN_SET]: 'Admin set',
};
const CONSENT_COLORS: Record<SlackUserLinkConsentState, TagColor> = {
[SLACK_USER_LINK_CONSENT_STATE.ACTIVE]: 'green',
[SLACK_USER_LINK_CONSENT_STATE.PENDING]: 'orange',
[SLACK_USER_LINK_CONSENT_STATE.DECLINED]: 'red',
[SLACK_USER_LINK_CONSENT_STATE.ADMIN_SET]: 'gray',
};
const DISCONNECTED_WORKSPACE_LABEL = 'Slack workspace disconnected';
const isFromDisconnectedSlackWorkspace = ({
slackUserLink,
installedSlackTeamId,
}: {
slackUserLink: SlackUserLinkRecord;
installedSlackTeamId: string | undefined;
}): boolean =>
isNonEmptyString(installedSlackTeamId) &&
isNonEmptyString(slackUserLink.slackTeamId) &&
slackUserLink.slackTeamId !== installedSlackTeamId;
type SlackUserLinksListProps = {
slackUserLinks: SlackUserLinkRecord[];
canManage: boolean;
installedSlackTeamId: string | undefined;
hasMore?: boolean;
onRemove: (slackUserLink: SlackUserLinkRecord) => void;
onResend: (slackUserLink: SlackUserLinkRecord) => void;
removingLinkId: string | undefined;
resendingLinkId: string | undefined;
};
export const SlackUserLinksList = ({
slackUserLinks,
canManage,
installedSlackTeamId,
hasMore = false,
onRemove,
onResend,
removingLinkId,
resendingLinkId,
}: SlackUserLinksListProps) => {
const [removalArmedLinkId, setRemovalArmedLinkId] = useState<string | null>(
null,
);
useEffect(() => {
if (removalArmedLinkId === null) {
return undefined;
}
const disarmTimer = setTimeout(
() => setRemovalArmedLinkId(null),
REMOVAL_CONFIRM_TIMEOUT_MS,
);
return () => clearTimeout(disarmTimer);
}, [removalArmedLinkId]);
const isActionInFlight =
isDefined(removingLinkId) || isDefined(resendingLinkId);
if (slackUserLinks.length === 0) {
return <StyledEmptyState>No Slack user links yet.</StyledEmptyState>;
}
return (
<SlackTable>
<SlackTableRow gridTemplateColumns={LINKS_GRID_TEMPLATE_COLUMNS}>
<SlackTableHeader>Slack account</SlackTableHeader>
<SlackTableHeader>Workspace member</SlackTableHeader>
<SlackTableHeader>Status</SlackTableHeader>
<SlackTableHeader align="right" />
</SlackTableRow>
<SlackTableBody>
{slackUserLinks.map((slackUserLink) => {
const isDisconnected = isFromDisconnectedSlackWorkspace({
slackUserLink,
installedSlackTeamId,
});
const consentState = toDisplayedConsentState(
slackUserLink.consentState,
);
const isPending =
!isDisconnected &&
consentState === SLACK_USER_LINK_CONSENT_STATE.PENDING;
const displayedName =
slackUserLink.name ?? slackUserLink.slackUserId ?? 'Unnamed link';
return (
<SlackTableRow
key={slackUserLink.id}
gridTemplateColumns={LINKS_GRID_TEMPLATE_COLUMNS}
>
<SlackTableCell>
<StyledIdentity>
<Avatar
placeholder={displayedName}
placeholderColorSeed={slackUserLink.id}
type="rounded"
size="md"
/>
<StyledDetails>
<StyledName>
<OverflowingTextWithTooltip text={displayedName} />
</StyledName>
<StyledMeta>
{slackUserLink.slackUserId ?? 'unknown'} ·{' '}
{slackUserLink.slackTeamId ?? 'unknown'}
</StyledMeta>
</StyledDetails>
</StyledIdentity>
</SlackTableCell>
<SlackTableCell>
<OverflowingTextWithTooltip
text={
slackUserLink.workspaceMemberName ?? 'No workspace member'
}
/>
</SlackTableCell>
<SlackTableCell>
{isDisconnected ? (
<Tag color="gray" text={DISCONNECTED_WORKSPACE_LABEL} />
) : (
isDefined(consentState) && (
<Tag
color={CONSENT_COLORS[consentState]}
text={CONSENT_LABELS[consentState]}
/>
)
)}
<Tag
color={getSourceColor(slackUserLink.source)}
text={getSourceLabel(slackUserLink.source)}
/>
</SlackTableCell>
<SlackTableCell align="right">
{canManage && (
<>
{isPending && removalArmedLinkId !== slackUserLink.id && (
<Button
type="button"
title={
resendingLinkId === slackUserLink.id
? 'Resending…'
: 'Resend'
}
size="small"
variant="secondary"
disabled={isActionInFlight}
onClick={() => onResend(slackUserLink)}
/>
)}
{removalArmedLinkId === slackUserLink.id ? (
<Button
type="button"
title={
removingLinkId === slackUserLink.id
? 'Removing…'
: 'Confirm removal'
}
size="small"
variant="secondary"
accent="danger"
disabled={isActionInFlight}
onClick={() => {
setRemovalArmedLinkId(null);
onRemove(slackUserLink);
}}
/>
) : (
<Button
type="button"
title="Remove"
size="small"
variant="secondary"
disabled={isActionInFlight}
onClick={() => setRemovalArmedLinkId(slackUserLink.id)}
/>
)}
</>
)}
</SlackTableCell>
</SlackTableRow>
);
})}
{hasMore && (
<StyledEmptyState>
Showing {slackUserLinks.length} links; more exist than can be shown
here.
</StyledEmptyState>
)}
</SlackTableBody>
</SlackTable>
);
};

View File

@@ -0,0 +1,307 @@
import 'twenty-ui/style.css';
import 'twenty-ui/theme-dark.css';
import 'twenty-ui/theme-light.css';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { enqueueSnackbar, useColorScheme } from 'twenty-sdk/front-component';
import { isDefined } from 'twenty-sdk/utils';
import { Callout } from 'twenty-ui/feedback';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { ThemeProvider, themeCssVariables } from 'twenty-ui/theme-constants';
import { H2Title } from 'twenty-ui/typography';
import { SlackAccessModeSection } from 'src/front-components/components/SlackAccessModeSection';
import { SlackUserLinkForm } from 'src/front-components/components/SlackUserLinkForm';
import { SlackUserLinksList } from 'src/front-components/components/SlackUserLinksList';
import { UnlinkedSlackUsersList } from 'src/front-components/components/UnlinkedSlackUsersList';
import { SLACK_CONNECTION_HEALTH_CALLOUTS } from 'src/front-components/constants/slack-connection-health-callouts.constant';
import { useCanManageSlackUserLinks } from 'src/front-components/hooks/use-can-manage-slack-user-links';
import { useMatchSlackUserLinks } from 'src/front-components/hooks/use-match-slack-user-links';
import { useSlackConnectionStatus } from 'src/front-components/hooks/use-slack-connection-status';
import { useRemoveSlackUserLink } from 'src/front-components/hooks/use-remove-slack-user-link';
import { useResendSlackUserLinkConsent } from 'src/front-components/hooks/use-resend-slack-user-link-consent';
import { useSlackUserLinks } from 'src/front-components/hooks/use-slack-user-links';
import { useUnlinkedSlackUsers } from 'src/front-components/hooks/use-unlinked-slack-users';
import { type SlackUserLinkRecord } from 'src/front-components/types/slack-user-link-record.type';
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${() => themeCssVariables.spacing[8]};
width: 100%;
`;
const StyledMatchAction = styled.div`
align-items: center;
display: flex;
gap: ${() => themeCssVariables.spacing[3]};
justify-content: flex-end;
padding-top: ${() => themeCssVariables.spacing[2]};
`;
const StyledMatchSummary = styled.span`
color: ${() => themeCssVariables.font.color.secondary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.xs};
`;
const StyledDisclosure = styled.div`
align-self: flex-start;
`;
const StyledCenteredState = styled.div`
align-items: center;
box-sizing: border-box;
color: ${() => themeCssVariables.font.color.tertiary};
display: flex;
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
height: 100%;
justify-content: center;
padding: ${() => themeCssVariables.spacing[4]};
width: 100%;
`;
const SlackUserLinksSettingsContent = () => {
const { canManage, isPermissionLoading } = useCanManageSlackUserLinks();
const {
isSlackConnected,
installedSlackTeamId,
connectionHealth,
hasRosterMatchFailed,
isConnectionStatusLoading,
} = useSlackConnectionStatus();
const {
slackUserLinks,
isSlackUserLinksLoading,
errorMessage,
hasMoreSlackUserLinks,
refetchSlackUserLinks,
} = useSlackUserLinks();
const { removeSlackUserLink, removingLinkId } = useRemoveSlackUserLink();
const { resendConsent, resendingLinkId } = useResendSlackUserLinkConsent();
const {
unlinkedSlackUsers,
hasMoreUnlinkedSlackUsers,
isUnlinkedSlackUsersLoading,
unlinkedErrorMessage,
refetchUnlinkedSlackUsers,
} = useUnlinkedSlackUsers({ isEnabled: canManage && isSlackConnected });
const { matchSlackUserLinks, isMatching } = useMatchSlackUserLinks();
const [matchSummary, setMatchSummary] = useState<string | undefined>(
undefined,
);
const [hasLastMatchRunFailed, setHasLastMatchRunFailed] = useState<
boolean | undefined
>(undefined);
const [isManualFormOpen, setIsManualFormOpen] = useState(false);
const handleLinkSaved = async () => {
await Promise.all([refetchSlackUserLinks(), refetchUnlinkedSlackUsers()]);
};
const handleManualLinkSaved = async () => {
setIsManualFormOpen(false);
await handleLinkSaved();
};
const handleMatchByEmail = async () => {
const result = await matchSlackUserLinks();
if (!result.success) {
setMatchSummary(undefined);
setHasLastMatchRunFailed(true);
enqueueSnackbar({
message: isNonEmptyString(result.error) ? result.error : result.message,
variant: 'error',
});
return;
}
setMatchSummary(result.message);
setHasLastMatchRunFailed(result.failedCount > 0);
await handleLinkSaved();
};
const handleRemove = async (slackUserLink: SlackUserLinkRecord) => {
const result = await removeSlackUserLink(slackUserLink.id);
enqueueSnackbar({
message: isNonEmptyString(result.error) ? result.error : result.message,
variant: result.success ? 'success' : 'error',
});
if (result.success) {
await handleLinkSaved();
}
};
const handleResend = async (slackUserLink: SlackUserLinkRecord) => {
if (
!isNonEmptyString(slackUserLink.slackTeamId) ||
!isNonEmptyString(slackUserLink.slackUserId)
) {
enqueueSnackbar({
message:
'This link is missing its Slack ids, so the request cannot be resent.',
variant: 'error',
});
return;
}
const result = await resendConsent({
id: slackUserLink.id,
slackTeamId: slackUserLink.slackTeamId,
slackUserId: slackUserLink.slackUserId,
});
enqueueSnackbar({
message: isNonEmptyString(result.error) ? result.error : result.message,
variant: result.success ? 'success' : 'error',
});
if (result.success) {
await refetchSlackUserLinks();
}
};
if (isConnectionStatusLoading || !isSlackConnected) {
return null;
}
const connectionHealthCallout = isDefined(connectionHealth)
? SLACK_CONNECTION_HEALTH_CALLOUTS[connectionHealth]
: undefined;
if (isDefined(connectionHealthCallout)) {
return (
<StyledContainer>
<Callout
variant="error"
title={connectionHealthCallout.title}
description={connectionHealthCallout.description}
/>
</StyledContainer>
);
}
if (isPermissionLoading) {
return <StyledCenteredState>Loading Slack user links…</StyledCenteredState>;
}
const shouldWarnAboutRosterMatch =
canManage && (hasLastMatchRunFailed ?? hasRosterMatchFailed);
return (
<StyledContainer>
{!canManage && (
<Callout
variant="warning"
title="You need the roles permission"
description="Only members with the roles permission can create or change Slack user links. You can review the existing links below."
/>
)}
{shouldWarnAboutRosterMatch && (
<Callout
variant="warning"
title="Email auto-link did not finish"
description="The last automatic email match failed before linking everyone. Press Auto-link by email below to run it again."
/>
)}
<SlackAccessModeSection canManage={canManage} />
{canManage && (
<Section>
<H2Title
title="Unlinked Slack users"
description="These Slack users talk to the assistant with its default role. Pick a workspace member on a row to link them in place, or auto-link everyone whose Slack email matches a workspace member."
/>
{isUnlinkedSlackUsersLoading && unlinkedSlackUsers.length === 0 ? (
<StyledCenteredState>
Loading unlinked Slack users…
</StyledCenteredState>
) : isDefined(unlinkedErrorMessage) ? (
<StyledCenteredState>{unlinkedErrorMessage}</StyledCenteredState>
) : (
<UnlinkedSlackUsersList
unlinkedSlackUsers={unlinkedSlackUsers}
hasMore={hasMoreUnlinkedSlackUsers}
onLinkSaved={handleLinkSaved}
/>
)}
<StyledMatchAction>
{isDefined(matchSummary) && (
<StyledMatchSummary>{matchSummary}</StyledMatchSummary>
)}
<Button
type="button"
title={isMatching ? 'Auto-linking…' : 'Auto-link by email'}
size="small"
variant="secondary"
disabled={isMatching}
onClick={handleMatchByEmail}
/>
</StyledMatchAction>
</Section>
)}
<Section>
<H2Title
title="Slack user links"
description="Each link maps a Slack account to the workspace member whose permissions the assistant borrows."
/>
{isSlackUserLinksLoading && slackUserLinks.length === 0 ? (
<StyledCenteredState>Loading links…</StyledCenteredState>
) : isDefined(errorMessage) ? (
<StyledCenteredState>{errorMessage}</StyledCenteredState>
) : (
<SlackUserLinksList
slackUserLinks={slackUserLinks}
canManage={canManage}
installedSlackTeamId={installedSlackTeamId}
hasMore={hasMoreSlackUserLinks}
onRemove={handleRemove}
onResend={handleResend}
removingLinkId={removingLinkId}
resendingLinkId={resendingLinkId}
/>
)}
</Section>
{canManage &&
(isManualFormOpen ? (
<SlackUserLinkForm
existingLinks={slackUserLinks}
onLinkSaved={handleManualLinkSaved}
/>
) : (
<StyledDisclosure>
<Button
type="button"
title="Link someone not listed above"
size="small"
variant="secondary"
onClick={() => setIsManualFormOpen(true)}
/>
</StyledDisclosure>
))}
</StyledContainer>
);
};
// twenty-ui components and this app's styled rules read every token off
// ThemeContext as a var() reference. The sandbox document does not inherit the
// host stylesheet, so the theme variable sheets are imported here for the
// provider to resolve them against.
export const SlackUserLinksSettings = () => {
const colorScheme = useColorScheme();
return (
<ThemeProvider colorScheme={colorScheme}>
<SlackUserLinksSettingsContent />
</ThemeProvider>
);
};

View File

@@ -0,0 +1,43 @@
import { useState } from 'react';
import { SearchDropdownPicker } from 'src/front-components/components/SearchDropdownPicker';
import { useSlackUserSearch } from 'src/front-components/hooks/use-slack-user-search';
import { type SlackResolvedUser } from 'src/logic-functions/types/slack-resolved-user.type';
type SlackUserPickerProps = {
onSelect: (slackUser: SlackResolvedUser) => void;
disabled?: boolean;
autoFocus?: boolean;
};
export const SlackUserPicker = ({
onSelect,
disabled,
autoFocus,
}: SlackUserPickerProps) => {
const [searchTerm, setSearchTerm] = useState('');
const { options, isSearching, searchErrorMessage } =
useSlackUserSearch(searchTerm);
return (
<SearchDropdownPicker
searchTerm={searchTerm}
onSearchTermChange={setSearchTerm}
options={options}
isSearching={isSearching}
onSelect={onSelect}
getOptionKey={(slackUser) => slackUser.slackUserId}
getOptionName={(slackUser) =>
slackUser.displayName ?? slackUser.slackUserId
}
getOptionMeta={(slackUser) => slackUser.email}
searchLabel="Search Slack by name or email"
emptyText={
searchErrorMessage ??
'No Slack users found. For a guest or Slack Connect user, link by Slack ID below.'
}
disabled={disabled}
autoFocus={autoFocus}
/>
);
};

View File

@@ -0,0 +1,89 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
// twenty-ui's Toggle is a base-ui Switch whose click handler constructs a
// PointerEvent, which the front component sandbox does not provide. This is
// the same control as a plain button, styled after twenty-ui's Toggle. The
// host theme squircles every corner, so the capsule and thumb opt out.
const TOGGLE_WIDTH_PIXELS = 32;
const TOGGLE_HEIGHT_PIXELS = 20;
const THUMB_SIZE_PIXELS = 16;
const THUMB_INSET_PIXELS = 2;
const CHECKED_THUMB_OFFSET_PIXELS =
TOGGLE_WIDTH_PIXELS - THUMB_SIZE_PIXELS - THUMB_INSET_PIXELS;
const StyledToggle = styled.button`
align-items: center;
appearance: none;
background-color: ${() => themeCssVariables.background.transparent.medium};
border: none;
border-radius: ${() => themeCssVariables.border.radius.pill};
corner-shape: round;
cursor: pointer;
display: flex;
flex-shrink: 0;
height: ${TOGGLE_HEIGHT_PIXELS}px;
padding: 0;
position: relative;
transition: background-color
calc(${() => themeCssVariables.animation.duration.normal} * 1s) ease;
width: ${TOGGLE_WIDTH_PIXELS}px;
&[data-checked='true'] {
background-color: ${() => themeCssVariables.color.blue};
}
&:disabled {
cursor: default;
opacity: 0.5;
}
`;
const StyledThumb = styled.span`
background-color: ${() => themeCssVariables.background.primary};
border-radius: 50%;
corner-shape: round;
display: block;
height: ${THUMB_SIZE_PIXELS}px;
left: 0;
position: absolute;
top: 50%;
transform: translate(${THUMB_INSET_PIXELS}px, -50%);
transition: transform
calc(${() => themeCssVariables.animation.duration.normal} * 1s) ease;
width: ${THUMB_SIZE_PIXELS}px;
&[data-checked='true'] {
transform: translate(${CHECKED_THUMB_OFFSET_PIXELS}px, -50%);
}
`;
type ToggleProps = {
id?: string;
checked: boolean;
disabled?: boolean;
ariaLabel: string;
onChange: (checked: boolean) => void;
};
export const Toggle = ({
id,
checked,
disabled = false,
ariaLabel,
onChange,
}: ToggleProps) => (
<StyledToggle
type="button"
role="switch"
id={id}
aria-checked={checked ? 'true' : 'false'}
aria-label={ariaLabel}
data-checked={checked ? 'true' : 'false'}
disabled={disabled}
onClick={() => onChange(!checked)}
>
<StyledThumb data-checked={checked ? 'true' : 'false'} />
</StyledToggle>
);

View File

@@ -0,0 +1,140 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import { isDefined } from 'twenty-sdk/utils';
import { Avatar } from 'twenty-ui/data-display';
import { Button } from 'twenty-ui/input';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { InlineWorkspaceMemberPicker } from 'src/front-components/components/InlineWorkspaceMemberPicker';
import {
SlackTableCell,
SlackTableRow,
} from 'src/front-components/components/SlackSettingsTable';
import { useSetSlackUserLink } from 'src/front-components/hooks/use-set-slack-user-link';
import { type WorkspaceMemberOption } from 'src/front-components/types/workspace-member-option.type';
import { type SlackResolvedUser } from 'src/logic-functions/types/slack-resolved-user.type';
const StyledIdentity = styled.div`
align-items: center;
display: flex;
gap: ${() => themeCssVariables.spacing[2]};
min-width: 0;
`;
const StyledName = styled.div`
color: ${() => themeCssVariables.font.color.primary};
min-width: 0;
`;
const StyledNoEmail = styled.span`
color: ${() => themeCssVariables.font.color.tertiary};
`;
const StyledMemberCell = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: ${() => themeCssVariables.spacing[2]};
justify-content: space-between;
min-width: 0;
`;
const StyledLinkControls = styled.div`
align-items: center;
display: flex;
flex-shrink: 0;
gap: ${() => themeCssVariables.spacing[2]};
`;
type UnlinkedSlackUserRowProps = {
slackUser: SlackResolvedUser;
gridTemplateColumns: string;
onLinkSaved: () => void;
};
export const UnlinkedSlackUserRow = ({
slackUser,
gridTemplateColumns,
onLinkSaved,
}: UnlinkedSlackUserRowProps) => {
const [selectedMember, setSelectedMember] =
useState<WorkspaceMemberOption | null>(null);
const { setSlackUserLink, isSubmitting } = useSetSlackUserLink();
const displayedName = isNonEmptyString(slackUser.displayName)
? slackUser.displayName
: slackUser.slackUserId;
const handleLink = async () => {
if (!isDefined(selectedMember)) {
return;
}
const result = await setSlackUserLink({
workspaceMemberId: selectedMember.id,
slackUserId: slackUser.slackUserId,
slackTeamId: isNonEmptyString(slackUser.slackTeamId)
? slackUser.slackTeamId
: undefined,
name: slackUser.displayName,
});
enqueueSnackbar({
message: isNonEmptyString(result.error) ? result.error : result.message,
variant: result.success ? 'success' : 'error',
});
if (result.success) {
setSelectedMember(null);
onLinkSaved();
}
};
return (
<SlackTableRow gridTemplateColumns={gridTemplateColumns}>
<SlackTableCell>
<StyledIdentity>
<Avatar
placeholder={displayedName}
placeholderColorSeed={slackUser.slackUserId}
type="rounded"
size="md"
/>
<StyledName>
<OverflowingTextWithTooltip text={displayedName} />
</StyledName>
</StyledIdentity>
</SlackTableCell>
<SlackTableCell>
{isNonEmptyString(slackUser.email) ? (
<OverflowingTextWithTooltip text={slackUser.email} />
) : (
<StyledNoEmail>No confirmed email</StyledNoEmail>
)}
</SlackTableCell>
<SlackTableCell>
<StyledMemberCell>
<InlineWorkspaceMemberPicker
selectedMember={selectedMember}
onSelect={setSelectedMember}
disabled={isSubmitting}
/>
<StyledLinkControls>
<Button
type="button"
title={isSubmitting ? 'Linking…' : 'Link'}
size="small"
variant="secondary"
accent="blue"
disabled={!isDefined(selectedMember) || isSubmitting}
onClick={handleLink}
/>
</StyledLinkControls>
</StyledMemberCell>
</SlackTableCell>
</SlackTableRow>
);
};

View File

@@ -0,0 +1,76 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
SlackTable,
SlackTableBody,
SlackTableHeader,
SlackTableRow,
} from 'src/front-components/components/SlackSettingsTable';
import { UnlinkedSlackUserRow } from 'src/front-components/components/UnlinkedSlackUserRow';
import { type SlackResolvedUser } from 'src/logic-functions/types/slack-resolved-user.type';
const UNLINKED_GRID_TEMPLATE_COLUMNS = '2fr 2fr 3fr';
const StyledEmptyState = styled.div`
color: ${() => themeCssVariables.font.color.tertiary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
padding: ${() => themeCssVariables.spacing[2]};
`;
const StyledTruncationNote = styled.div`
color: ${() => themeCssVariables.font.color.tertiary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.xs};
padding: ${() => themeCssVariables.spacing[1]}
${() => themeCssVariables.spacing[2]};
`;
type UnlinkedSlackUsersListProps = {
unlinkedSlackUsers: SlackResolvedUser[];
hasMore: boolean;
onLinkSaved: () => void;
};
export const UnlinkedSlackUsersList = ({
unlinkedSlackUsers,
hasMore,
onLinkSaved,
}: UnlinkedSlackUsersListProps) => {
if (unlinkedSlackUsers.length === 0) {
return (
<StyledEmptyState>
{hasMore
? 'The Slack roster is too large to scan in one pass. Find a specific person with the search below.'
: 'Everyone in the Slack workspace is linked.'}
</StyledEmptyState>
);
}
return (
<SlackTable>
<SlackTableRow gridTemplateColumns={UNLINKED_GRID_TEMPLATE_COLUMNS}>
<SlackTableHeader>Slack user</SlackTableHeader>
<SlackTableHeader>Email</SlackTableHeader>
<SlackTableHeader>Workspace member</SlackTableHeader>
</SlackTableRow>
<SlackTableBody>
{unlinkedSlackUsers.map((slackUser) => (
<UnlinkedSlackUserRow
key={slackUser.slackUserId}
slackUser={slackUser}
gridTemplateColumns={UNLINKED_GRID_TEMPLATE_COLUMNS}
onLinkSaved={onLinkSaved}
/>
))}
{hasMore && (
<StyledTruncationNote>
There may be more unlinked users. Link some of these first, or find
a specific person with the search below.
</StyledTruncationNote>
)}
</SlackTableBody>
</SlackTable>
);
};

View File

@@ -0,0 +1,65 @@
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-sdk/utils';
import { useState } from 'react';
import { SearchDropdownPicker } from 'src/front-components/components/SearchDropdownPicker';
import { SlackPickedEntityButton } from 'src/front-components/components/SlackPickedEntityButton';
import { useWorkspaceMemberSearch } from 'src/front-components/hooks/use-workspace-member-search';
import { type WorkspaceMemberOption } from 'src/front-components/types/workspace-member-option.type';
import { getMemberDisplayName } from 'src/front-components/utils/get-member-display-name.util';
type WorkspaceMemberPickerProps = {
selectedMember: WorkspaceMemberOption | null;
onSelect: (member: WorkspaceMemberOption) => void;
onClear: () => void;
disabled?: boolean;
};
export const WorkspaceMemberPicker = ({
selectedMember,
onSelect,
onClear,
disabled,
}: WorkspaceMemberPickerProps) => {
const [searchTerm, setSearchTerm] = useState('');
const [isReopening, setIsReopening] = useState(false);
const { options, isSearching, searchErrorMessage } = useWorkspaceMemberSearch(
{ searchTerm },
);
if (isDefined(selectedMember)) {
return (
<SlackPickedEntityButton
name={getMemberDisplayName(selectedMember)}
changeLabel="Change the workspace member"
onChangeRequest={() => {
setIsReopening(true);
onClear();
}}
disabled={disabled}
/>
);
}
return (
<SearchDropdownPicker
searchTerm={searchTerm}
onSearchTermChange={setSearchTerm}
options={options}
isSearching={isSearching}
onSelect={(member) => {
setIsReopening(false);
onSelect(member);
}}
getOptionKey={(member) => member.id}
getOptionName={(member) =>
isNonEmptyString(member.name) ? member.name : member.id
}
getOptionMeta={(member) => member.userEmail ?? undefined}
searchLabel="Search a workspace member by name"
emptyText={searchErrorMessage ?? 'No members found'}
disabled={disabled}
autoFocus={isReopening}
/>
);
};

View File

@@ -0,0 +1,26 @@
import { type CalloutProps } from 'twenty-ui/feedback';
import {
SLACK_CONNECTION_HEALTH,
type SlackConnectionHealth,
} from 'src/logic-functions/constants/slack-connection-health';
export const SLACK_CONNECTION_HEALTH_CALLOUTS: Partial<
Record<SlackConnectionHealth, Pick<CalloutProps, 'title' | 'description'>>
> = {
[SLACK_CONNECTION_HEALTH.TOKEN_REJECTED]: {
title: 'Slack connection is no longer valid',
description:
'Slack rejected the stored credentials, so the assistant cannot read or send messages. Remove the Slack connection and add it again.',
},
[SLACK_CONNECTION_HEALTH.TEAM_CLAIMED_BY_ANOTHER_WORKSPACE]: {
title: 'Slack workspace already connected elsewhere',
description:
'This Slack workspace is registered to another Twenty workspace, so its messages are delivered there. Disconnect Slack in that workspace first, then remove the connection here and add it again.',
},
[SLACK_CONNECTION_HEALTH.TEAM_UNCLAIMED]: {
title: 'Slack connection needs to be registered again',
description:
'No Twenty workspace is registered for this Slack workspace, so its messages are not delivered anywhere. Remove the Slack connection and add it again.',
},
};

View File

@@ -0,0 +1,99 @@
import { useEffect, useRef, useState } from 'react';
import { useResolveSlackUser } from 'src/front-components/hooks/use-resolve-slack-user';
import { isResolvableSlackIdentity } from 'src/front-components/utils/is-resolvable-slack-identity.util';
import {
type SlackResolveInput,
toSlackResolveInput,
} from 'src/front-components/utils/to-slack-resolve-input.util';
import { type SlackResolvedUser } from 'src/logic-functions/types/slack-resolved-user.type';
const AUTO_RESOLVE_DEBOUNCE_MS = 600;
type RawSlackIdentity = {
slackUserId: string;
slackTeamId: string;
};
export const useAutoResolveSlackUser = () => {
const [resolvedUser, setResolvedUser] = useState<SlackResolvedUser | null>(
null,
);
const [resolveError, setResolveError] = useState<string | null>(null);
const resolveRequestIdRef = useRef(0);
const autoResolveTimerRef = useRef<ReturnType<typeof setTimeout>>();
const { resolveSlackUser, isResolving } = useResolveSlackUser();
useEffect(() => () => clearTimeout(autoResolveTimerRef.current), []);
const resolveIdentity = async (input: SlackResolveInput) => {
const requestId = resolveRequestIdRef.current;
const result = await resolveSlackUser(input);
if (requestId !== resolveRequestIdRef.current) {
return;
}
if (!result.success) {
setResolvedUser(null);
setResolveError(result.error);
return;
}
setResolvedUser(result.slackUser);
setResolveError(null);
};
const clearResolution = () => {
resolveRequestIdRef.current += 1;
clearTimeout(autoResolveTimerRef.current);
setResolvedUser(null);
setResolveError(null);
};
const onIdentityChange = (rawIdentity: RawSlackIdentity) => {
clearResolution();
const input = toSlackResolveInput(rawIdentity);
if (!isResolvableSlackIdentity(input)) {
return;
}
autoResolveTimerRef.current = setTimeout(() => {
resolveIdentity(input);
}, AUTO_RESOLVE_DEBOUNCE_MS);
};
const selectResolvedUser = (slackUser: SlackResolvedUser) => {
clearResolution();
setResolvedUser(slackUser);
};
const resolveNow = (rawIdentity: RawSlackIdentity) => {
const input = toSlackResolveInput(rawIdentity);
if (!isResolvableSlackIdentity(input)) {
setResolveError(
'Search for the Slack user by name or email, or enter their full Slack user id.',
);
return;
}
clearResolution();
resolveIdentity(input);
};
return {
resolvedUser,
resolveError,
isResolving,
onIdentityChange,
resolveNow,
selectResolvedUser,
clearResolution,
};
};

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from 'react';
import { currentUserHasRolesPermission } from 'src/logic-functions/utils/current-user-has-roles-permission';
type CanManageSlackUserLinksState = {
canManage: boolean;
isPermissionLoading: boolean;
};
const LOADING_STATE: CanManageSlackUserLinksState = {
canManage: false,
isPermissionLoading: true,
};
export const useCanManageSlackUserLinks = (): CanManageSlackUserLinksState => {
const [state, setState] =
useState<CanManageSlackUserLinksState>(LOADING_STATE);
useEffect(() => {
let cancelled = false;
setState(LOADING_STATE);
const fetchPermission = async () => {
const canManage = await currentUserHasRolesPermission();
if (!cancelled) {
setState({ canManage, isPermissionLoading: false });
}
};
fetchPermission();
return () => {
cancelled = true;
};
}, []);
return state;
};

View File

@@ -0,0 +1,51 @@
import { useRef, useState } from 'react';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { SLACK_USER_LINKS_MATCH_ROUTE_PATH } from 'src/constants/slack-user-links-route-path.constant';
import { type SlackRosterMatchOutcome } from 'src/front-components/types/slack-roster-match-outcome.type';
import { parseSlackRosterMatchResult } from 'src/front-components/utils/parse-slack-roster-match-result.util';
const FALLBACK_MATCH_ERROR_MESSAGE =
'Could not match Slack users. Please try again.';
const buildFailureOutcome = (error: string): SlackRosterMatchOutcome => ({
success: false,
message: FALLBACK_MATCH_ERROR_MESSAGE,
error,
linkedCount: 0,
unmatchedCount: 0,
failedCount: 0,
});
export const useMatchSlackUserLinks = () => {
const isMatchingRef = useRef(false);
const [isMatching, setIsMatching] = useState(false);
const matchSlackUserLinks = async (): Promise<SlackRosterMatchOutcome> => {
if (isMatchingRef.current) {
return buildFailureOutcome('A match is already running.');
}
isMatchingRef.current = true;
setIsMatching(true);
try {
const result = await new RestApiClient().post(
`/s${SLACK_USER_LINKS_MATCH_ROUTE_PATH}`,
{},
);
return parseSlackRosterMatchResult({
value: result,
fallbackMessage: FALLBACK_MATCH_ERROR_MESSAGE,
});
} catch {
return buildFailureOutcome(FALLBACK_MATCH_ERROR_MESSAGE);
} finally {
isMatchingRef.current = false;
setIsMatching(false);
}
};
return { matchSlackUserLinks, isMatching };
};

View File

@@ -0,0 +1,21 @@
import { SLACK_USER_LINKS_REMOVE_ROUTE_PATH } from 'src/constants/slack-user-links-route-path.constant';
import { useSlackToolPost } from 'src/front-components/hooks/use-slack-tool-post';
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
type RemoveSlackUserLinkState = {
removeSlackUserLink: (id: string) => Promise<SlackToolResult>;
removingLinkId: string | undefined;
};
export const useRemoveSlackUserLink = (): RemoveSlackUserLinkState => {
const { postSlackTool, inFlightLinkId } = useSlackToolPost({
routePath: SLACK_USER_LINKS_REMOVE_ROUTE_PATH,
fallbackMessage: 'Could not remove the link',
busyError: 'Another removal is still in progress. Please wait.',
});
return {
removeSlackUserLink: (id) => postSlackTool({ linkId: id, payload: { id } }),
removingLinkId: inFlightLinkId,
};
};

View File

@@ -0,0 +1,29 @@
import { SLACK_USER_LINKS_RESEND_CONSENT_ROUTE_PATH } from 'src/constants/slack-user-links-route-path.constant';
import { useSlackToolPost } from 'src/front-components/hooks/use-slack-tool-post';
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
type ResendConsentInput = {
id: string;
slackTeamId: string;
slackUserId: string;
};
type ResendSlackUserLinkConsentState = {
resendConsent: (input: ResendConsentInput) => Promise<SlackToolResult>;
resendingLinkId: string | undefined;
};
export const useResendSlackUserLinkConsent =
(): ResendSlackUserLinkConsentState => {
const { postSlackTool, inFlightLinkId } = useSlackToolPost({
routePath: SLACK_USER_LINKS_RESEND_CONSENT_ROUTE_PATH,
fallbackMessage: 'Could not resend the consent request',
busyError: 'Another consent request is still being sent. Please wait.',
});
return {
resendConsent: ({ id, slackTeamId, slackUserId }) =>
postSlackTool({ linkId: id, payload: { slackTeamId, slackUserId } }),
resendingLinkId: inFlightLinkId,
};
};

View File

@@ -0,0 +1,42 @@
import { useState } from 'react';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { SLACK_USER_LINKS_RESOLVE_ROUTE_PATH } from 'src/constants/slack-user-links-route-path.constant';
import {
GENERIC_RESOLVE_ERROR,
parseResolveSlackUserResult,
type ResolveSlackUserResult,
} from 'src/front-components/utils/parse-resolve-slack-user-result.util';
import { type SlackResolveInput } from 'src/front-components/utils/to-slack-resolve-input.util';
type ResolveSlackUserState = {
resolveSlackUser: (
input: SlackResolveInput,
) => Promise<ResolveSlackUserResult>;
isResolving: boolean;
};
export const useResolveSlackUser = (): ResolveSlackUserState => {
const [inFlightResolveCount, setInFlightResolveCount] = useState(0);
const resolveSlackUser = async (
input: SlackResolveInput,
): Promise<ResolveSlackUserResult> => {
setInFlightResolveCount((count) => count + 1);
try {
const result = await new RestApiClient().post(
`/s${SLACK_USER_LINKS_RESOLVE_ROUTE_PATH}`,
input,
);
return parseResolveSlackUserResult(result);
} catch {
return GENERIC_RESOLVE_ERROR;
} finally {
setInFlightResolveCount((count) => count - 1);
}
};
return { resolveSlackUser, isResolving: inFlightResolveCount > 0 };
};

View File

@@ -0,0 +1,48 @@
import { useState } from 'react';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { SLACK_USER_LINKS_SET_ROUTE_PATH } from 'src/constants/slack-user-links-route-path.constant';
import { parseSlackToolResult } from 'src/front-components/utils/parse-slack-tool-result.util';
import { type SlackSetUserLinkInput } from 'src/logic-functions/types/slack-set-user-link-input.type';
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
const FALLBACK_MESSAGE = 'Could not save the link';
const GENERIC_ERROR_RESULT: SlackToolResult = {
success: false,
message: FALLBACK_MESSAGE,
error: 'The request failed. Please try again.',
};
type SetSlackUserLinkState = {
setSlackUserLink: (input: SlackSetUserLinkInput) => Promise<SlackToolResult>;
isSubmitting: boolean;
};
export const useSetSlackUserLink = (): SetSlackUserLinkState => {
const [isSubmitting, setIsSubmitting] = useState(false);
const setSlackUserLink = async (
input: SlackSetUserLinkInput,
): Promise<SlackToolResult> => {
setIsSubmitting(true);
try {
const result = await new RestApiClient().post(
`/s${SLACK_USER_LINKS_SET_ROUTE_PATH}`,
input,
);
return parseSlackToolResult({
value: result,
fallbackMessage: FALLBACK_MESSAGE,
});
} catch {
return GENERIC_ERROR_RESULT;
} finally {
setIsSubmitting(false);
}
};
return { setSlackUserLink, isSubmitting };
};

View File

@@ -0,0 +1,127 @@
import { isBoolean, isString } from '@sniptt/guards';
import { useEffect, useState } from 'react';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { isDefined } from 'twenty-sdk/utils';
import {
SLACK_ACCESS_MODE_GET_ROUTE_PATH,
SLACK_ACCESS_MODE_SET_ROUTE_PATH,
} from 'src/constants/slack-access-mode-route-path.constant';
import { SLACK_ACCESS_MODE } from 'src/logic-functions/constants/slack-access-mode';
import { type SlackAccessMode } from 'src/logic-functions/types/slack-access-mode.type';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
import { isSlackAccessMode } from 'src/logic-functions/utils/is-slack-access-mode';
type SaveAccessModeResult = {
success: boolean;
message: string;
error?: string;
};
type SlackAccessModeState = {
accessMode: SlackAccessMode;
hasAccessModeError: boolean;
isAccessModeLoading: boolean;
isSavingAccessMode: boolean;
saveAccessMode: (
accessMode: SlackAccessMode,
) => Promise<SaveAccessModeResult>;
};
const GENERIC_ERROR_RESULT: SaveAccessModeResult = {
success: false,
message: 'Could not save the access mode',
error: 'The request failed. Please try again.',
};
export const useSlackAccessMode = (): SlackAccessModeState => {
const [accessMode, setAccessMode] = useState<SlackAccessMode>(
SLACK_ACCESS_MODE.ANYONE,
);
const [hasAccessModeError, setHasAccessModeError] = useState(false);
const [isAccessModeLoading, setIsAccessModeLoading] = useState(true);
const [isSavingAccessMode, setIsSavingAccessMode] = useState(false);
useEffect(() => {
let cancelled = false;
const fetchAccessMode = async () => {
try {
const result = await new RestApiClient().get(
`/s${SLACK_ACCESS_MODE_GET_ROUTE_PATH}`,
);
if (!cancelled) {
const record = asRecord(result);
const readAccessMode = record?.accessMode;
if (isSlackAccessMode(readAccessMode)) {
setAccessMode(readAccessMode);
}
setHasAccessModeError(
record?.isAccessModeReadable === false ||
!isSlackAccessMode(readAccessMode),
);
}
} catch {
if (!cancelled) {
setHasAccessModeError(true);
}
} finally {
if (!cancelled) {
setIsAccessModeLoading(false);
}
}
};
fetchAccessMode();
return () => {
cancelled = true;
};
}, []);
const saveAccessMode = async (
nextAccessMode: SlackAccessMode,
): Promise<SaveAccessModeResult> => {
setIsSavingAccessMode(true);
try {
const result = await new RestApiClient().post(
`/s${SLACK_ACCESS_MODE_SET_ROUTE_PATH}`,
{ accessMode: nextAccessMode },
);
const record = asRecord(result);
if (!isDefined(record) || !isBoolean(record.success)) {
return GENERIC_ERROR_RESULT;
}
if (record.success) {
setAccessMode(nextAccessMode);
setHasAccessModeError(false);
}
return {
success: record.success,
message: isString(record.message) ? record.message : '',
error: isString(record.error) ? record.error : undefined,
};
} catch {
return GENERIC_ERROR_RESULT;
} finally {
setIsSavingAccessMode(false);
}
};
return {
accessMode,
hasAccessModeError,
isAccessModeLoading,
isSavingAccessMode,
saveAccessMode,
};
};

View File

@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { SLACK_USER_LINKS_CONNECTION_STATUS_ROUTE_PATH } from 'src/constants/slack-user-links-route-path.constant';
import { type ParsedSlackConnectionStatus } from 'src/front-components/types/parsed-slack-connection-status.type';
import {
DISCONNECTED_SLACK_CONNECTION_STATUS,
parseSlackConnectionStatus,
} from 'src/front-components/utils/parse-slack-connection-status.util';
type SlackConnectionStatusState = ParsedSlackConnectionStatus & {
isConnectionStatusLoading: boolean;
};
const LOADING_STATE: SlackConnectionStatusState = {
...DISCONNECTED_SLACK_CONNECTION_STATUS,
isConnectionStatusLoading: true,
};
export const useSlackConnectionStatus = (): SlackConnectionStatusState => {
const [state, setState] = useState<SlackConnectionStatusState>(LOADING_STATE);
useEffect(() => {
let cancelled = false;
const fetchConnectionStatus = async () => {
let connectionStatus: ParsedSlackConnectionStatus;
try {
const result = await new RestApiClient().post(
`/s${SLACK_USER_LINKS_CONNECTION_STATUS_ROUTE_PATH}`,
{},
);
connectionStatus = parseSlackConnectionStatus(result);
} catch {
connectionStatus = DISCONNECTED_SLACK_CONNECTION_STATUS;
}
if (!cancelled) {
setState({ ...connectionStatus, isConnectionStatusLoading: false });
}
};
fetchConnectionStatus();
return () => {
cancelled = true;
};
}, []);
return state;
};

View File

@@ -0,0 +1,64 @@
import { useRef, useState } from 'react';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
import { parseSlackToolResult } from 'src/front-components/utils/parse-slack-tool-result.util';
type SlackToolPostState = {
postSlackTool: (input: {
linkId: string;
payload: Record<string, unknown>;
}) => Promise<SlackToolResult>;
inFlightLinkId: string | undefined;
};
export const useSlackToolPost = ({
routePath,
fallbackMessage,
busyError,
}: {
routePath: string;
fallbackMessage: string;
busyError: string;
}): SlackToolPostState => {
const [inFlightLinkId, setInFlightLinkId] = useState<string | undefined>(
undefined,
);
const isPostingRef = useRef(false);
const postSlackTool = async ({
linkId,
payload,
}: {
linkId: string;
payload: Record<string, unknown>;
}): Promise<SlackToolResult> => {
if (isPostingRef.current) {
return {
success: false,
message: fallbackMessage,
error: busyError,
};
}
isPostingRef.current = true;
setInFlightLinkId(linkId);
try {
const result = await new RestApiClient().post(`/s${routePath}`, payload);
return parseSlackToolResult({ value: result, fallbackMessage });
} catch {
return {
success: false,
message: fallbackMessage,
error: 'The request failed. Please try again.',
};
} finally {
isPostingRef.current = false;
setInFlightLinkId(undefined);
}
};
return { postSlackTool, inFlightLinkId };
};

View File

@@ -0,0 +1,126 @@
import { isNonEmptyString, isNumber } from '@sniptt/guards';
import { useCallback, useEffect, useRef, useState } from 'react';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { type SlackUserLinkRecord } from 'src/front-components/types/slack-user-link-record.type';
import { formatWorkspaceMemberName } from 'src/front-components/utils/format-workspace-member-name.util';
const SLACK_USER_LINKS_PAGE_SIZE = 200;
const SLACK_USER_LINKS_ERROR_MESSAGE =
'Could not load Slack user links. Please try again later.';
type SlackUserLinkRestRecord = {
id?: string | null;
name?: string | null;
slackUserId?: string | null;
slackTeamId?: string | null;
source?: string | null;
consentState?: string | null;
workspaceMemberId?: string | null;
workspaceMember?: {
id?: string | null;
name?: { firstName?: string | null; lastName?: string | null } | null;
} | null;
};
type SlackUserLinksResponse = {
data?: { slackUserLinks?: SlackUserLinkRestRecord[] | null } | null;
totalCount?: number | null;
};
type SlackUserLinksState = {
slackUserLinks: SlackUserLinkRecord[];
isSlackUserLinksLoading: boolean;
errorMessage: string | undefined;
hasMoreSlackUserLinks: boolean;
refetchSlackUserLinks: () => Promise<void>;
};
export const useSlackUserLinks = (): SlackUserLinksState => {
const [slackUserLinks, setSlackUserLinks] = useState<SlackUserLinkRecord[]>(
[],
);
const [isSlackUserLinksLoading, setIsSlackUserLinksLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | undefined>(
undefined,
);
const [hasMoreSlackUserLinks, setHasMoreSlackUserLinks] = useState(false);
const fetchIdRef = useRef(0);
const fetchSlackUserLinks = useCallback(async () => {
fetchIdRef.current += 1;
const fetchId = fetchIdRef.current;
setIsSlackUserLinksLoading(true);
setErrorMessage(undefined);
try {
const response = await new RestApiClient().get<SlackUserLinksResponse>(
'/rest/slackUserLinks',
{
query: {
depth: '1',
limit: String(SLACK_USER_LINKS_PAGE_SIZE),
},
},
);
const records: SlackUserLinkRecord[] = [];
for (const record of response.data?.slackUserLinks ?? []) {
if (!isNonEmptyString(record.id)) {
continue;
}
records.push({
id: record.id,
name: record.name ?? null,
slackUserId: record.slackUserId ?? null,
slackTeamId: record.slackTeamId ?? null,
source: record.source ?? null,
consentState: record.consentState ?? null,
workspaceMemberId: record.workspaceMemberId ?? null,
workspaceMemberName: record.workspaceMember
? formatWorkspaceMemberName(record.workspaceMember.name)
: null,
});
}
records.sort((left, right) =>
(left.name ?? left.slackUserId ?? '').localeCompare(
right.name ?? right.slackUserId ?? '',
),
);
if (fetchId !== fetchIdRef.current) {
return;
}
setHasMoreSlackUserLinks(
isNumber(response.totalCount) && response.totalCount > records.length,
);
setSlackUserLinks(records);
} catch {
if (fetchId === fetchIdRef.current) {
setErrorMessage(SLACK_USER_LINKS_ERROR_MESSAGE);
}
} finally {
if (fetchId === fetchIdRef.current) {
setIsSlackUserLinksLoading(false);
}
}
}, []);
useEffect(() => {
fetchSlackUserLinks();
}, [fetchSlackUserLinks]);
return {
slackUserLinks,
isSlackUserLinksLoading,
errorMessage,
hasMoreSlackUserLinks,
refetchSlackUserLinks: fetchSlackUserLinks,
};
};

View File

@@ -0,0 +1,79 @@
import { isNonEmptyString } from '@sniptt/guards';
import { useEffect, useState } from 'react';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { SLACK_USER_LINKS_SEARCH_ROUTE_PATH } from 'src/constants/slack-user-links-route-path.constant';
import {
FALLBACK_SEARCH_ERROR_MESSAGE,
parseSlackUserSearchResponse,
} from 'src/front-components/utils/parse-slack-user-search-response.util';
import { type SlackResolvedUser } from 'src/logic-functions/types/slack-resolved-user.type';
const SLACK_USER_SEARCH_DEBOUNCE_MS = 400;
type SlackUserSearchState = {
options: SlackResolvedUser[];
isSearching: boolean;
searchErrorMessage: string | undefined;
};
export const useSlackUserSearch = (
searchTerm: string,
): SlackUserSearchState => {
const [options, setOptions] = useState<SlackResolvedUser[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [searchErrorMessage, setSearchErrorMessage] = useState<
string | undefined
>(undefined);
useEffect(() => {
const trimmedSearchTerm = searchTerm.trim();
if (!isNonEmptyString(trimmedSearchTerm)) {
setOptions([]);
setSearchErrorMessage(undefined);
setIsSearching(false);
return;
}
let cancelled = false;
setOptions([]);
setSearchErrorMessage(undefined);
setIsSearching(true);
const timeoutId = setTimeout(async () => {
try {
const result = await new RestApiClient().post(
`/s${SLACK_USER_LINKS_SEARCH_ROUTE_PATH}`,
{ query: trimmedSearchTerm },
);
if (!cancelled) {
const { options: parsedOptions, errorMessage } =
parseSlackUserSearchResponse(result);
setOptions(parsedOptions);
setSearchErrorMessage(errorMessage);
}
} catch {
if (!cancelled) {
setOptions([]);
setSearchErrorMessage(FALLBACK_SEARCH_ERROR_MESSAGE);
}
} finally {
if (!cancelled) {
setIsSearching(false);
}
}
}, SLACK_USER_SEARCH_DEBOUNCE_MS);
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [searchTerm]);
return { options, isSearching, searchErrorMessage };
};

View File

@@ -0,0 +1,93 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { isDefined } from 'twenty-sdk/utils';
import { SLACK_USER_LINKS_UNLINKED_ROUTE_PATH } from 'src/constants/slack-user-links-route-path.constant';
import { parseSlackUserSearchResponse } from 'src/front-components/utils/parse-slack-user-search-response.util';
import { type SlackResolvedUser } from 'src/logic-functions/types/slack-resolved-user.type';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
const UNLINKED_USERS_ERROR_MESSAGE =
'Could not load unlinked Slack users. Please try again later.';
type UnlinkedSlackUsersState = {
unlinkedSlackUsers: SlackResolvedUser[];
hasMoreUnlinkedSlackUsers: boolean;
isUnlinkedSlackUsersLoading: boolean;
unlinkedErrorMessage: string | undefined;
refetchUnlinkedSlackUsers: () => Promise<void>;
};
export const useUnlinkedSlackUsers = ({
isEnabled,
}: {
isEnabled: boolean;
}): UnlinkedSlackUsersState => {
const [unlinkedSlackUsers, setUnlinkedSlackUsers] = useState<
SlackResolvedUser[]
>([]);
const [hasMoreUnlinkedSlackUsers, setHasMoreUnlinkedSlackUsers] =
useState(false);
const [isUnlinkedSlackUsersLoading, setIsUnlinkedSlackUsersLoading] =
useState(true);
const [unlinkedErrorMessage, setUnlinkedErrorMessage] = useState<
string | undefined
>(undefined);
const fetchIdRef = useRef(0);
const refetchUnlinkedSlackUsers = useCallback(async () => {
fetchIdRef.current += 1;
const fetchId = fetchIdRef.current;
setIsUnlinkedSlackUsersLoading(true);
setUnlinkedErrorMessage(undefined);
try {
const result = await new RestApiClient().post(
`/s${SLACK_USER_LINKS_UNLINKED_ROUTE_PATH}`,
{},
);
if (fetchId !== fetchIdRef.current) {
return;
}
const { options, errorMessage } = parseSlackUserSearchResponse(result);
if (isDefined(errorMessage)) {
setUnlinkedErrorMessage(errorMessage);
setUnlinkedSlackUsers([]);
setHasMoreUnlinkedSlackUsers(false);
return;
}
setUnlinkedSlackUsers(options);
setHasMoreUnlinkedSlackUsers(asRecord(result)?.hasMore === true);
} catch {
if (fetchId === fetchIdRef.current) {
setUnlinkedErrorMessage(UNLINKED_USERS_ERROR_MESSAGE);
}
} finally {
if (fetchId === fetchIdRef.current) {
setIsUnlinkedSlackUsersLoading(false);
}
}
}, []);
useEffect(() => {
if (!isEnabled) {
return;
}
refetchUnlinkedSlackUsers();
}, [isEnabled, refetchUnlinkedSlackUsers]);
return {
unlinkedSlackUsers,
hasMoreUnlinkedSlackUsers,
isUnlinkedSlackUsersLoading,
unlinkedErrorMessage,
refetchUnlinkedSlackUsers,
};
};

View File

@@ -0,0 +1,114 @@
import { isNonEmptyString } from '@sniptt/guards';
import { useEffect, useState } from 'react';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { type WorkspaceMemberOption } from 'src/front-components/types/workspace-member-option.type';
import { buildWorkspaceMemberSearchFilter } from 'src/front-components/utils/build-workspace-member-search-filter.util';
import { formatWorkspaceMemberName } from 'src/front-components/utils/format-workspace-member-name.util';
const WORKSPACE_MEMBER_SEARCH_DEBOUNCE_MS = 250;
const WORKSPACE_MEMBER_SEARCH_PAGE_SIZE = 20;
const MEMBER_SEARCH_ERROR_MESSAGE =
'Workspace member search failed. Try again.';
type WorkspaceMemberRestRecord = {
id?: string | null;
name?: { firstName?: string | null; lastName?: string | null } | null;
userEmail?: string | null;
};
type WorkspaceMembersResponse = {
data?: { workspaceMembers?: WorkspaceMemberRestRecord[] | null } | null;
};
type WorkspaceMemberSearchState = {
options: WorkspaceMemberOption[];
isSearching: boolean;
searchErrorMessage: string | undefined;
};
export const useWorkspaceMemberSearch = ({
searchTerm,
shouldListWithoutSearchTerm = false,
}: {
searchTerm: string;
shouldListWithoutSearchTerm?: boolean;
}): WorkspaceMemberSearchState => {
const [options, setOptions] = useState<WorkspaceMemberOption[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [searchErrorMessage, setSearchErrorMessage] = useState<
string | undefined
>(undefined);
useEffect(() => {
const filter = buildWorkspaceMemberSearchFilter(searchTerm);
setOptions([]);
setSearchErrorMessage(undefined);
if (
!isNonEmptyString(filter) &&
(!shouldListWithoutSearchTerm || isNonEmptyString(searchTerm))
) {
setIsSearching(false);
return;
}
let cancelled = false;
setIsSearching(true);
const timeoutId = setTimeout(async () => {
try {
const response =
await new RestApiClient().get<WorkspaceMembersResponse>(
'/rest/workspaceMembers',
{
query: {
...(isNonEmptyString(filter) ? { filter } : {}),
limit: String(WORKSPACE_MEMBER_SEARCH_PAGE_SIZE),
},
},
);
if (cancelled) {
return;
}
const memberOptions: WorkspaceMemberOption[] = [];
for (const record of response.data?.workspaceMembers ?? []) {
if (!isNonEmptyString(record.id)) {
continue;
}
memberOptions.push({
id: record.id,
name: formatWorkspaceMemberName(record.name),
userEmail: record.userEmail ?? null,
});
}
setOptions(memberOptions);
} catch {
if (!cancelled) {
setOptions([]);
setSearchErrorMessage(MEMBER_SEARCH_ERROR_MESSAGE);
}
} finally {
if (!cancelled) {
setIsSearching(false);
}
}
}, WORKSPACE_MEMBER_SEARCH_DEBOUNCE_MS);
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [searchTerm, shouldListWithoutSearchTerm]);
return { options, isSearching, searchErrorMessage };
};

View File

@@ -0,0 +1,13 @@
import { defineSettingsFrontComponent } from 'twenty-sdk/define';
import { SLACK_USER_LINKS_SETTINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { SlackUserLinksSettings } from 'src/front-components/components/SlackUserLinksSettings';
export default defineSettingsFrontComponent({
universalIdentifier:
SLACK_USER_LINKS_SETTINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'slack-user-links-settings',
description:
'Manage Slack user links: review existing links and, with the roles permission, link a Slack account to a workspace member.',
component: SlackUserLinksSettings,
});

View File

@@ -0,0 +1,8 @@
import { type SlackConnectionHealth } from 'src/logic-functions/constants/slack-connection-health';
export type ParsedSlackConnectionStatus = {
isSlackConnected: boolean;
installedSlackTeamId: string | undefined;
connectionHealth: SlackConnectionHealth | undefined;
hasRosterMatchFailed: boolean;
};

View File

@@ -0,0 +1,8 @@
export type SlackRosterMatchOutcome = {
success: boolean;
message: string;
error?: string;
linkedCount: number;
unmatchedCount: number;
failedCount: number;
};

View File

@@ -0,0 +1,10 @@
export type SlackUserLinkRecord = {
id: string;
name: string | null;
slackUserId: string | null;
slackTeamId: string | null;
source: string | null;
consentState: string | null;
workspaceMemberId: string | null;
workspaceMemberName: string | null;
};

View File

@@ -0,0 +1,5 @@
export type WorkspaceMemberOption = {
id: string;
name: string;
userEmail: string | null;
};

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest';
import { buildSlackUserLinkSaveNote } from 'src/front-components/utils/build-slack-user-link-save-note.util';
import { type SlackUserLinkRecord } from 'src/front-components/types/slack-user-link-record.type';
import { type WorkspaceMemberOption } from 'src/front-components/types/workspace-member-option.type';
import { type SlackResolvedUser } from 'src/logic-functions/types/slack-resolved-user.type';
const buildResolvedUser = (
overrides: Partial<SlackResolvedUser> = {},
): SlackResolvedUser => ({
slackUserId: 'U0123456789',
slackTeamId: 'T0INSTALLED',
displayName: 'Ada Lovelace',
email: 'ada@twenty.com',
isInInstalledWorkspace: true,
...overrides,
});
const MEMBER: WorkspaceMemberOption = {
id: 'member-1',
name: 'Ada Member',
userEmail: 'ada@twenty.com',
};
const buildExistingLink = (
overrides: Partial<SlackUserLinkRecord> = {},
): SlackUserLinkRecord => ({
id: 'link-1',
name: 'Ada Lovelace',
slackUserId: 'U0123456789',
slackTeamId: 'T0INSTALLED',
source: 'MANUAL',
consentState: 'ACTIVE',
workspaceMemberId: 'member-1',
workspaceMemberName: 'Ada Member',
...overrides,
});
describe('buildSlackUserLinkSaveNote', () => {
it('should announce an admin-set link for an outside-workspace account', () => {
expect(
buildSlackUserLinkSaveNote({
resolvedUser: buildResolvedUser({ isInInstalledWorkspace: false }),
selectedMember: MEMBER,
existingLink: undefined,
}),
).toContain('admin-set');
});
it('should nudge for a member before promising an outcome', () => {
expect(
buildSlackUserLinkSaveNote({
resolvedUser: buildResolvedUser(),
selectedMember: null,
existingLink: undefined,
}),
).toContain('Pick the workspace member');
});
it('should announce immediate activation when the emails match', () => {
expect(
buildSlackUserLinkSaveNote({
resolvedUser: buildResolvedUser({ email: 'Ada@Twenty.com' }),
selectedMember: MEMBER,
existingLink: undefined,
}),
).toContain('activates immediately');
});
it('should announce the approval request for a mismatched member', () => {
expect(
buildSlackUserLinkSaveNote({
resolvedUser: buildResolvedUser(),
selectedMember: { ...MEMBER, userEmail: 'bob@twenty.com' },
existingLink: undefined,
}),
).toContain('send Ada Lovelace an approval request');
});
it('should say a same-member re-save changes nothing', () => {
expect(
buildSlackUserLinkSaveNote({
resolvedUser: buildResolvedUser({ email: 'Ada@Twenty.com' }),
selectedMember: MEMBER,
existingLink: buildExistingLink(),
}),
).toContain('changes nothing');
});
it('should say a pending same-member link gets no new request', () => {
expect(
buildSlackUserLinkSaveNote({
resolvedUser: buildResolvedUser(),
selectedMember: MEMBER,
existingLink: buildExistingLink({ consentState: 'PENDING' }),
}),
).toContain('sends no new request');
});
it('should say a declined same-member link will not re-ask', () => {
expect(
buildSlackUserLinkSaveNote({
resolvedUser: buildResolvedUser(),
selectedMember: MEMBER,
existingLink: buildExistingLink({ consentState: 'DECLINED' }),
}),
).toContain('will not ask them again');
});
it('should announce replacement plus the outcome when re-pointing to another member', () => {
const note = buildSlackUserLinkSaveNote({
resolvedUser: buildResolvedUser(),
selectedMember: { ...MEMBER, id: 'member-2', userEmail: 'bob@twenty.com' },
existingLink: buildExistingLink(),
});
expect(note).toContain('saving replaces that link');
expect(note).toContain('approval request');
});
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { buildWorkspaceMemberSearchFilter } from 'src/front-components/utils/build-workspace-member-search-filter.util';
describe('buildWorkspaceMemberSearchFilter', () => {
it('matches a single word against first and last name', () => {
expect(buildWorkspaceMemberSearchFilter('ada')).toBe(
'or(name.firstName[ilike]:%ada%,name.lastName[ilike]:%ada%)',
);
});
it('requires every word of a full name to match a name part', () => {
expect(buildWorkspaceMemberSearchFilter('Ada Lovelace')).toBe(
'and(or(name.firstName[ilike]:%Ada%,name.lastName[ilike]:%Ada%),or(name.firstName[ilike]:%Lovelace%,name.lastName[ilike]:%Lovelace%))',
);
});
it('strips filter-breaking characters and treats them as separators', () => {
expect(buildWorkspaceMemberSearchFilter('Lovelace,Ada')).toBe(
'and(or(name.firstName[ilike]:%Lovelace%,name.lastName[ilike]:%Lovelace%),or(name.firstName[ilike]:%Ada%,name.lastName[ilike]:%Ada%))',
);
});
it('keeps the marks that appear inside real names', () => {
expect(buildWorkspaceMemberSearchFilter("O'Brien")).toBe(
"or(name.firstName[ilike]:%O'Brien%,name.lastName[ilike]:%O'Brien%)",
);
expect(buildWorkspaceMemberSearchFilter('Jean-Luc')).toBe(
'or(name.firstName[ilike]:%Jean-Luc%,name.lastName[ilike]:%Jean-Luc%)',
);
expect(buildWorkspaceMemberSearchFilter('Ada Lovelace')).toContain('Ada');
});
it('drops characters that could reach the query string or the filter grammar', () => {
expect(buildWorkspaceMemberSearchFilter('ada&limit=1000')).toBe(
'and(or(name.firstName[ilike]:%ada%,name.lastName[ilike]:%ada%),or(name.firstName[ilike]:%limit%,name.lastName[ilike]:%limit%),or(name.firstName[ilike]:%1000%,name.lastName[ilike]:%1000%))',
);
});
it('returns undefined when nothing searchable remains', () => {
expect(buildWorkspaceMemberSearchFilter(' %_() ')).toBeUndefined();
expect(buildWorkspaceMemberSearchFilter('')).toBeUndefined();
});
});

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { getMemberDisplayName } from 'src/front-components/utils/get-member-display-name.util';
describe('getMemberDisplayName', () => {
it('should prefer the name', () => {
expect(
getMemberDisplayName({
id: 'member-1',
name: 'Ada Lovelace',
userEmail: 'ada@twenty.com',
}),
).toBe('Ada Lovelace');
});
it('should fall back to the email when the name is empty', () => {
expect(
getMemberDisplayName({
id: 'member-1',
name: '',
userEmail: 'ada@twenty.com',
}),
).toBe('ada@twenty.com');
});
it('should fall back to the id when name and email are empty', () => {
expect(
getMemberDisplayName({ id: 'member-1', name: '', userEmail: null }),
).toBe('member-1');
});
});

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { isResolvableSlackIdentity } from 'src/front-components/utils/is-resolvable-slack-identity.util';
describe('isResolvableSlackIdentity', () => {
it('should accept a Slack-shaped user id', () => {
expect(
isResolvableSlackIdentity({
slackUserId: 'U0123456789',
}),
).toBe(true);
});
it('should accept a workspace-scoped user id', () => {
expect(
isResolvableSlackIdentity({
slackUserId: 'W0123456789',
}),
).toBe(true);
});
it('should reject an id still being typed', () => {
expect(isResolvableSlackIdentity({ slackUserId: 'U01' })).toBe(false);
});
it('should reject an id not shaped like a Slack user id', () => {
expect(
isResolvableSlackIdentity({
slackUserId: 'not-an-id',
}),
).toBe(false);
});
it('should reject an empty identity', () => {
expect(
isResolvableSlackIdentity({
slackUserId: undefined,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { parseResolveSlackUserResult } from 'src/front-components/utils/parse-resolve-slack-user-result.util';
describe('parseResolveSlackUserResult', () => {
it('should fail generically when the value is not a record', () => {
const result = parseResolveSlackUserResult('nope');
expect(result.success).toBe(false);
});
it('should fail generically when success is not a boolean', () => {
const result = parseResolveSlackUserResult({ success: 'yes' });
expect(result.success).toBe(false);
});
it('should surface the server error, then message, then the fallback', () => {
expect(
parseResolveSlackUserResult({ success: false, error: 'Not allowed' }),
).toEqual({ success: false, error: 'Not allowed' });
expect(
parseResolveSlackUserResult({ success: false, message: 'Nope' }),
).toEqual({ success: false, error: 'Nope' });
const fallback = parseResolveSlackUserResult({ success: false });
expect(fallback.success).toBe(false);
if (fallback.success === false) {
expect(fallback.error).toContain('try again');
}
});
it('should fail generically when the slack user is missing or lacks an id', () => {
expect(parseResolveSlackUserResult({ success: true }).success).toBe(false);
expect(
parseResolveSlackUserResult({ success: true, slackUser: { email: 'a' } })
.success,
).toBe(false);
});
it('should parse a populated slack user', () => {
expect(
parseResolveSlackUserResult({
success: true,
slackUser: {
slackUserId: 'U1',
slackTeamId: 'T1',
displayName: 'Ada',
email: 'ada@twenty.com',
isInInstalledWorkspace: true,
},
}),
).toEqual({
success: true,
slackUser: {
slackUserId: 'U1',
slackTeamId: 'T1',
displayName: 'Ada',
email: 'ada@twenty.com',
isInInstalledWorkspace: true,
},
});
});
});

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest';
import { parseSlackConnectionStatus } from 'src/front-components/utils/parse-slack-connection-status.util';
describe('parseSlackConnectionStatus', () => {
it('should parse a healthy connected status with its installed team id', () => {
expect(
parseSlackConnectionStatus({
success: true,
isConnected: true,
installedSlackTeamId: 'T0INSTALLED',
connectionHealth: 'ok',
}),
).toEqual({
isSlackConnected: true,
installedSlackTeamId: 'T0INSTALLED',
connectionHealth: 'ok',
hasRosterMatchFailed: false,
});
});
it('should leave the team id undefined when the payload omits it', () => {
expect(
parseSlackConnectionStatus({ success: true, isConnected: true }),
).toEqual({
isSlackConnected: true,
installedSlackTeamId: undefined,
connectionHealth: undefined,
hasRosterMatchFailed: false,
});
});
it('should ignore a team id that is not a non-empty string', () => {
for (const installedSlackTeamId of ['', 42, null, {}]) {
expect(
parseSlackConnectionStatus({
success: true,
isConnected: true,
installedSlackTeamId,
}),
).toEqual({
isSlackConnected: true,
installedSlackTeamId: undefined,
connectionHealth: undefined,
hasRosterMatchFailed: false,
});
}
});
it('should parse a broken connection health', () => {
expect(
parseSlackConnectionStatus({
success: true,
isConnected: true,
connectionHealth: 'team_claimed_by_another_workspace',
}),
).toEqual({
isSlackConnected: true,
installedSlackTeamId: undefined,
connectionHealth: 'team_claimed_by_another_workspace',
hasRosterMatchFailed: false,
});
});
it('should drop an unknown health value', () => {
expect(
parseSlackConnectionStatus({
isConnected: true,
connectionHealth: 'exploded',
}),
).toEqual({
isSlackConnected: true,
installedSlackTeamId: undefined,
connectionHealth: undefined,
hasRosterMatchFailed: false,
});
});
it('should surface a recorded roster match failure', () => {
expect(
parseSlackConnectionStatus({
isConnected: true,
connectionHealth: 'ok',
hasRosterMatchFailed: true,
}),
).toEqual({
isSlackConnected: true,
installedSlackTeamId: undefined,
connectionHealth: 'ok',
hasRosterMatchFailed: true,
});
});
it('should report a disconnected status', () => {
expect(
parseSlackConnectionStatus({
success: true,
isConnected: false,
installedSlackTeamId: 'T0INSTALLED',
}),
).toEqual({
isSlackConnected: false,
installedSlackTeamId: undefined,
connectionHealth: undefined,
hasRosterMatchFailed: false,
});
});
it('should fall back on a malformed payload', () => {
for (const value of [undefined, null, 'connected', []]) {
expect(parseSlackConnectionStatus(value)).toEqual({
isSlackConnected: false,
installedSlackTeamId: undefined,
connectionHealth: undefined,
hasRosterMatchFailed: false,
});
}
});
});

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { parseSlackRosterMatchResult } from 'src/front-components/utils/parse-slack-roster-match-result.util';
const FALLBACK_MESSAGE = 'Could not match Slack users. Please try again.';
describe('parseSlackRosterMatchResult', () => {
it('should parse a successful match summary', () => {
const result = parseSlackRosterMatchResult({
value: {
success: true,
message: 'Matched 2 Slack users by email.',
linkedCount: 2,
alreadyLinkedCount: 1,
unmatchedCount: 3,
failedCount: 1,
},
fallbackMessage: FALLBACK_MESSAGE,
});
expect(result.success).toBe(true);
expect(result.message).toBe('Matched 2 Slack users by email.');
expect(result.linkedCount).toBe(2);
expect(result.unmatchedCount).toBe(3);
expect(result.failedCount).toBe(1);
});
it('should default counts to zero when missing', () => {
const result = parseSlackRosterMatchResult({
value: { success: true, message: 'ok' },
fallbackMessage: FALLBACK_MESSAGE,
});
expect(result.linkedCount).toBe(0);
expect(result.unmatchedCount).toBe(0);
expect(result.failedCount).toBe(0);
});
it('should ignore non-numeric counts', () => {
const result = parseSlackRosterMatchResult({
value: {
success: true,
message: 'ok',
linkedCount: 'two',
unmatchedCount: null,
},
fallbackMessage: FALLBACK_MESSAGE,
});
expect(result.linkedCount).toBe(0);
expect(result.unmatchedCount).toBe(0);
});
it('should fall back on a malformed payload', () => {
const result = parseSlackRosterMatchResult({
value: undefined,
fallbackMessage: FALLBACK_MESSAGE,
});
expect(result.success).toBe(false);
expect(result.message).toBe(FALLBACK_MESSAGE);
expect(result.linkedCount).toBe(0);
expect(result.unmatchedCount).toBe(0);
});
});

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { parseSlackToolResult } from 'src/front-components/utils/parse-slack-tool-result.util';
const FALLBACK_MESSAGE = 'Could not do the thing';
describe('parseSlackToolResult', () => {
it('should fail with the fallback message when the value is not a record', () => {
expect(
parseSlackToolResult({
value: 'nope',
fallbackMessage: FALLBACK_MESSAGE,
}),
).toEqual({
success: false,
message: FALLBACK_MESSAGE,
error: 'The request failed. Please try again.',
});
});
it('should fail with the fallback message when success is not a boolean', () => {
expect(
parseSlackToolResult({
value: { success: 'yes' },
fallbackMessage: FALLBACK_MESSAGE,
}),
).toEqual({
success: false,
message: FALLBACK_MESSAGE,
error: 'The request failed. Please try again.',
});
});
it('should pass through a populated result', () => {
expect(
parseSlackToolResult({
value: {
success: false,
message: 'Not allowed',
error: 'No permission',
},
fallbackMessage: FALLBACK_MESSAGE,
}),
).toEqual({
success: false,
message: 'Not allowed',
error: 'No permission',
});
});
it('should default non-string message and error fields', () => {
expect(
parseSlackToolResult({
value: { success: true, message: 42, error: null },
fallbackMessage: FALLBACK_MESSAGE,
}),
).toEqual({ success: true, message: FALLBACK_MESSAGE, error: undefined });
});
it('should fall back on an empty message so the snackbar is never blank', () => {
expect(
parseSlackToolResult({
value: { success: true, message: '' },
fallbackMessage: FALLBACK_MESSAGE,
}),
).toEqual({ success: true, message: FALLBACK_MESSAGE, error: undefined });
});
});

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import {
FALLBACK_SEARCH_ERROR_MESSAGE,
parseSlackUserSearchResponse,
} from 'src/front-components/utils/parse-slack-user-search-response.util';
describe('parseSlackUserSearchResponse', () => {
it('should fall back to the generic error when the value is not a record', () => {
expect(parseSlackUserSearchResponse('nope')).toEqual({
options: [],
errorMessage: FALLBACK_SEARCH_ERROR_MESSAGE,
});
});
it('should surface the server error on failure, falling back when missing', () => {
expect(
parseSlackUserSearchResponse({ success: false, error: 'Not allowed' }),
).toEqual({ options: [], errorMessage: 'Not allowed' });
expect(parseSlackUserSearchResponse({ success: false })).toEqual({
options: [],
errorMessage: FALLBACK_SEARCH_ERROR_MESSAGE,
});
});
it('should return no options and no error when slackUsers is not an array', () => {
expect(
parseSlackUserSearchResponse({ success: true, slackUsers: 'nope' }),
).toEqual({ options: [], errorMessage: undefined });
});
it('should map valid entries and skip malformed or teamless ones', () => {
expect(
parseSlackUserSearchResponse({
success: true,
slackUsers: [
'not-a-record',
{ displayName: 'No Id' },
{ slackUserId: 'U2', slackTeamId: '' },
{
slackUserId: 'U1',
slackTeamId: 'T1',
displayName: 'Ada',
email: 'ada@twenty.com',
},
],
}),
).toEqual({
options: [
{
slackUserId: 'U1',
slackTeamId: 'T1',
displayName: 'Ada',
email: 'ada@twenty.com',
isInInstalledWorkspace: true,
},
],
errorMessage: undefined,
});
});
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { toSlackResolveInput } from 'src/front-components/utils/to-slack-resolve-input.util';
describe('toSlackResolveInput', () => {
it('should trim every field', () => {
expect(
toSlackResolveInput({
slackUserId: ' U0123456789 ',
slackTeamId: ' T0INSTALLED ',
}),
).toEqual({
slackUserId: 'U0123456789',
slackTeamId: 'T0INSTALLED',
});
});
it('should drop whitespace-only fields as absent', () => {
expect(
toSlackResolveInput({
slackUserId: '\t',
slackTeamId: ' T0INSTALLED ',
}),
).toEqual({
slackUserId: undefined,
slackTeamId: 'T0INSTALLED',
});
});
it('should drop empty fields as absent', () => {
expect(toSlackResolveInput({ slackUserId: '', slackTeamId: '' })).toEqual({
slackUserId: undefined,
slackTeamId: undefined,
});
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { toSlackResolvedUser } from 'src/front-components/utils/to-slack-resolved-user.util';
describe('toSlackResolvedUser', () => {
it('should return undefined without a slack user id', () => {
expect(
toSlackResolvedUser({
record: { email: 'ada@twenty.com' },
isInInstalledWorkspace: true,
}),
).toBeUndefined();
});
it('should map a populated record and stamp the given workspace flag', () => {
expect(
toSlackResolvedUser({
record: {
slackUserId: 'U1',
slackTeamId: 'T1',
displayName: 'Ada',
email: 'ada@twenty.com',
},
isInInstalledWorkspace: false,
}),
).toEqual({
slackUserId: 'U1',
slackTeamId: 'T1',
displayName: 'Ada',
email: 'ada@twenty.com',
isInInstalledWorkspace: false,
});
});
it('should default a missing team to empty and non-string extras to undefined', () => {
expect(
toSlackResolvedUser({
record: { slackUserId: 'U1', displayName: 42, email: null },
isInInstalledWorkspace: true,
}),
).toEqual({
slackUserId: 'U1',
slackTeamId: '',
displayName: undefined,
email: undefined,
isInInstalledWorkspace: true,
});
});
});

View File

@@ -0,0 +1,60 @@
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-sdk/utils';
import { type SlackUserLinkRecord } from 'src/front-components/types/slack-user-link-record.type';
import { type WorkspaceMemberOption } from 'src/front-components/types/workspace-member-option.type';
import { SLACK_USER_LINK_CONSENT_STATE } from 'src/logic-functions/constants/slack-user-link-consent-state';
import { type SlackResolvedUser } from 'src/logic-functions/types/slack-resolved-user.type';
export const buildSlackUserLinkSaveNote = ({
resolvedUser,
selectedMember,
existingLink,
}: {
resolvedUser: SlackResolvedUser;
selectedMember: WorkspaceMemberOption | null;
existingLink: SlackUserLinkRecord | undefined;
}): string => {
if (!isDefined(selectedMember)) {
return 'Pick the workspace member this Slack account should act as.';
}
const isSameMemberRelink =
isDefined(existingLink) &&
existingLink.workspaceMemberId === selectedMember.id;
if (isSameMemberRelink) {
if (existingLink.consentState === SLACK_USER_LINK_CONSENT_STATE.DECLINED) {
return 'This person already declined this link, and saving will not ask them again. Remove the link and add it back to ask once more.';
}
if (existingLink.consentState === SLACK_USER_LINK_CONSENT_STATE.PENDING) {
return 'This link is already awaiting their approval; saving sends no new request. Use resend on the link below to nudge them.';
}
return 'This Slack account is already linked to this member, so saving changes nothing.';
}
const replacementPrefix = isDefined(existingLink)
? 'This Slack account is linked to another member; saving replaces that link. '
: '';
if (!resolvedUser.isInInstalledWorkspace) {
return `${replacementPrefix}This account is outside your Slack workspace, so the link is admin-set and active right away.`;
}
const matchesSelectedMember =
isNonEmptyString(resolvedUser.email) &&
isNonEmptyString(selectedMember.userEmail) &&
resolvedUser.email.toLowerCase() === selectedMember.userEmail.toLowerCase();
if (matchesSelectedMember) {
return `${replacementPrefix}The emails match, so the link activates immediately with no approval step.`;
}
const displayName = isNonEmptyString(resolvedUser.displayName)
? resolvedUser.displayName
: 'this person';
return `${replacementPrefix}Saving will send ${displayName} an approval request in Slack. The link activates once they approve.`;
};

View File

@@ -0,0 +1,30 @@
import { isNonEmptyString } from '@sniptt/guards';
// Allow-list rather than strip today's delimiters: the REST filter grammar can
// grow new ones, and a name only needs letters, digits and these marks.
const NON_NAME_CHARACTERS = /[^\p{L}\p{M}\p{N}'\-.]+/gu;
const toSearchWords = (searchTerm: string): string[] =>
searchTerm
.replace(NON_NAME_CHARACTERS, ' ')
.split(' ')
.filter(isNonEmptyString);
export const buildWorkspaceMemberSearchFilter = (
searchTerm: string,
): string | undefined => {
const words = toSearchWords(searchTerm);
if (words.length === 0) {
return undefined;
}
const wordFilters = words.map(
(word) =>
`or(name.firstName[ilike]:%${word}%,name.lastName[ilike]:%${word}%)`,
);
return wordFilters.length === 1
? wordFilters[0]
: `and(${wordFilters.join(',')})`;
};

View File

@@ -0,0 +1,18 @@
import { isNonEmptyString } from '@sniptt/guards';
type WorkspaceMemberName =
| {
firstName?: string | null;
lastName?: string | null;
}
| null
| undefined;
export const formatWorkspaceMemberName = (
name: WorkspaceMemberName,
): string => {
const firstName = name?.firstName?.trim() ?? '';
const lastName = name?.lastName?.trim() ?? '';
return [firstName, lastName].filter(isNonEmptyString).join(' ');
};

View File

@@ -0,0 +1,15 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type WorkspaceMemberOption } from 'src/front-components/types/workspace-member-option.type';
export const getMemberDisplayName = (member: WorkspaceMemberOption): string => {
if (isNonEmptyString(member.name)) {
return member.name;
}
if (isNonEmptyString(member.userEmail)) {
return member.userEmail;
}
return member.id;
};

View File

@@ -0,0 +1,10 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type SlackResolveInput } from 'src/front-components/utils/to-slack-resolve-input.util';
const SLACK_USER_ID_PATTERN = /^[UW][A-Z0-9]{7,}$/i;
export const isResolvableSlackIdentity = ({
slackUserId,
}: Pick<SlackResolveInput, 'slackUserId'>): boolean =>
isNonEmptyString(slackUserId) && SLACK_USER_ID_PATTERN.test(slackUserId);

View File

@@ -0,0 +1,51 @@
import { isBoolean, isString } from '@sniptt/guards';
import { isDefined } from 'twenty-sdk/utils';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
import { toSlackResolvedUser } from 'src/front-components/utils/to-slack-resolved-user.util';
import { type SlackResolvedUser } from 'src/logic-functions/types/slack-resolved-user.type';
export type ResolveSlackUserResult =
| { success: true; slackUser: SlackResolvedUser }
| { success: false; error: string };
export const GENERIC_RESOLVE_ERROR: ResolveSlackUserResult = {
success: false,
error: 'Could not resolve that Slack user. Please try again.',
};
export const parseResolveSlackUserResult = (
value: unknown,
): ResolveSlackUserResult => {
const record = asRecord(value);
if (!isDefined(record) || !isBoolean(record.success)) {
return GENERIC_RESOLVE_ERROR;
}
if (!record.success) {
return {
success: false,
error: isString(record.error)
? record.error
: isString(record.message)
? record.message
: GENERIC_RESOLVE_ERROR.error,
};
}
const slackUserRecord = asRecord(record.slackUser);
const resolvedUser = isDefined(slackUserRecord)
? toSlackResolvedUser({
record: slackUserRecord,
isInInstalledWorkspace: slackUserRecord.isInInstalledWorkspace === true,
})
: undefined;
if (!isDefined(resolvedUser)) {
return GENERIC_RESOLVE_ERROR;
}
return { success: true, slackUser: resolvedUser };
};

View File

@@ -0,0 +1,46 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type ParsedSlackConnectionStatus } from 'src/front-components/types/parsed-slack-connection-status.type';
import {
SLACK_CONNECTION_HEALTH,
type SlackConnectionHealth,
} from 'src/logic-functions/constants/slack-connection-health';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
export const DISCONNECTED_SLACK_CONNECTION_STATUS: ParsedSlackConnectionStatus =
{
isSlackConnected: false,
installedSlackTeamId: undefined,
connectionHealth: undefined,
hasRosterMatchFailed: false,
};
const KNOWN_HEALTH_VALUES: string[] = Object.values(SLACK_CONNECTION_HEALTH);
const isSlackConnectionHealth = (
value: unknown,
): value is SlackConnectionHealth =>
typeof value === 'string' && KNOWN_HEALTH_VALUES.includes(value);
export const parseSlackConnectionStatus = (
value: unknown,
): ParsedSlackConnectionStatus => {
const record = asRecord(value);
if (record?.isConnected !== true) {
return DISCONNECTED_SLACK_CONNECTION_STATUS;
}
const installedSlackTeamId = record.installedSlackTeamId;
return {
isSlackConnected: true,
installedSlackTeamId: isNonEmptyString(installedSlackTeamId)
? installedSlackTeamId
: undefined,
connectionHealth: isSlackConnectionHealth(record.connectionHealth)
? record.connectionHealth
: undefined,
hasRosterMatchFailed: record.hasRosterMatchFailed === true,
};
};

View File

@@ -0,0 +1,25 @@
import { isNumber } from '@sniptt/guards';
import { type SlackRosterMatchOutcome } from 'src/front-components/types/slack-roster-match-outcome.type';
import { parseSlackToolResult } from 'src/front-components/utils/parse-slack-tool-result.util';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
export const parseSlackRosterMatchResult = ({
value,
fallbackMessage,
}: {
value: unknown;
fallbackMessage: string;
}): SlackRosterMatchOutcome => {
const base = parseSlackToolResult({ value, fallbackMessage });
const record = asRecord(value);
return {
...base,
linkedCount: isNumber(record?.linkedCount) ? record.linkedCount : 0,
unmatchedCount: isNumber(record?.unmatchedCount)
? record.unmatchedCount
: 0,
failedCount: isNumber(record?.failedCount) ? record.failedCount : 0,
};
};

View File

@@ -0,0 +1,31 @@
import { isBoolean, isNonEmptyString, isString } from '@sniptt/guards';
import { isDefined } from 'twenty-sdk/utils';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
export const parseSlackToolResult = ({
value,
fallbackMessage,
}: {
value: unknown;
fallbackMessage: string;
}): SlackToolResult => {
const record = asRecord(value);
if (!isDefined(record) || !isBoolean(record.success)) {
return {
success: false,
message: fallbackMessage,
error: 'The request failed. Please try again.',
};
}
return {
success: record.success,
message: isNonEmptyString(record.message)
? record.message
: fallbackMessage,
error: isString(record.error) ? record.error : undefined,
};
};

View File

@@ -0,0 +1,54 @@
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-sdk/utils';
import { toSlackResolvedUser } from 'src/front-components/utils/to-slack-resolved-user.util';
import { type SlackResolvedUser } from 'src/logic-functions/types/slack-resolved-user.type';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
export type SlackUserSearchResponse = {
options: SlackResolvedUser[];
errorMessage: string | undefined;
};
export const FALLBACK_SEARCH_ERROR_MESSAGE =
'Slack user search failed. Try again.';
export const parseSlackUserSearchResponse = (
value: unknown,
): SlackUserSearchResponse => {
const record = asRecord(value);
if (!isDefined(record) || record.success !== true) {
const error = record?.error;
return {
options: [],
errorMessage: isNonEmptyString(error)
? error
: FALLBACK_SEARCH_ERROR_MESSAGE,
};
}
const slackUsers = Array.isArray(record.slackUsers) ? record.slackUsers : [];
const options: SlackResolvedUser[] = [];
for (const entry of slackUsers) {
const slackUserRecord = asRecord(entry);
const option = isDefined(slackUserRecord)
? toSlackResolvedUser({
record: slackUserRecord,
isInInstalledWorkspace: true,
})
: undefined;
if (!isDefined(option) || !isNonEmptyString(option.slackTeamId)) {
continue;
}
options.push(option);
}
return { options, errorMessage: undefined };
};

View File

@@ -0,0 +1,26 @@
import { isNonEmptyString } from '@sniptt/guards';
export type SlackResolveInput = {
slackUserId: string | undefined;
slackTeamId: string | undefined;
};
export const toSlackResolveInput = ({
slackUserId,
slackTeamId,
}: {
slackUserId: string;
slackTeamId: string;
}): SlackResolveInput => {
const trimmedSlackUserId = slackUserId.trim();
const trimmedSlackTeamId = slackTeamId.trim();
return {
slackUserId: isNonEmptyString(trimmedSlackUserId)
? trimmedSlackUserId
: undefined,
slackTeamId: isNonEmptyString(trimmedSlackTeamId)
? trimmedSlackTeamId
: undefined,
};
};

View File

@@ -0,0 +1,25 @@
import { isNonEmptyString, isString } from '@sniptt/guards';
import { type SlackResolvedUser } from 'src/logic-functions/types/slack-resolved-user.type';
export const toSlackResolvedUser = ({
record,
isInInstalledWorkspace,
}: {
record: Record<string, unknown>;
isInInstalledWorkspace: boolean;
}): SlackResolvedUser | undefined => {
if (!isNonEmptyString(record.slackUserId)) {
return undefined;
}
return {
slackUserId: record.slackUserId,
slackTeamId: isString(record.slackTeamId) ? record.slackTeamId : '',
displayName: isNonEmptyString(record.displayName)
? record.displayName
: undefined,
email: isNonEmptyString(record.email) ? record.email : undefined,
isInInstalledWorkspace,
};
};