Fix app design 1/2 (#19735)

comply with
https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=96977-349627&m=dev

## After
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 40
37"
src="https://github.com/user-attachments/assets/6d80191a-79a9-4f0f-aa4f-0e447fff4f6d"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 40
22"
src="https://github.com/user-attachments/assets/4f763272-027e-4246-b455-7d46babf7d8c"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 39
11"
src="https://github.com/user-attachments/assets/b9b35e18-8068-447e-821d-5ec28bb5bd16"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 39
05"
src="https://github.com/user-attachments/assets/57d9318a-902f-4fd7-a2a3-5795ebe0b9dc"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 39
02"
src="https://github.com/user-attachments/assets/78a33fa8-6bdd-484e-a82d-bd0f7592a623"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 38
58"
src="https://github.com/user-attachments/assets/f7987aed-c6e1-4032-a611-86817655137d"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 38
55"
src="https://github.com/user-attachments/assets/d1c451ab-1d2d-41e4-a059-cf4303ecabe7"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 38
48"
src="https://github.com/user-attachments/assets/593cae36-2320-443f-a955-93b211a6ee3f"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 37
40"
src="https://github.com/user-attachments/assets/c9f602b1-8de3-4e82-a3a6-344594a0c153"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 37
34"
src="https://github.com/user-attachments/assets/b54ddddf-5dda-46c8-ace3-cffe6015825a"
/>

## before

<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42
18"
src="https://github.com/user-attachments/assets/c0976a0a-0124-48ec-8e7c-78627cea7063"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42
16"
src="https://github.com/user-attachments/assets/d2db926c-4040-411d-9091-8b60e7c519e6"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42
13"
src="https://github.com/user-attachments/assets/2d69f2ff-f26e-4249-91a3-2cf3d261e840"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42
07"
src="https://github.com/user-attachments/assets/1028aabc-77ac-4c51-a8c3-9a194faba87f"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42
01"
src="https://github.com/user-attachments/assets/1caa9f5e-3eaa-433c-9d3b-e0f094f16e8e"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 41
56"
src="https://github.com/user-attachments/assets/f42b6976-3a8f-4591-9283-bda79bdb424b"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 41
53"
src="https://github.com/user-attachments/assets/93d00df8-0091-4dfa-9ac0-f6f376be5962"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 41
43"
src="https://github.com/user-attachments/assets/9deae7e5-39c1-4518-a463-6d79bc5bf132"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 41
37"
src="https://github.com/user-attachments/assets/3e21b521-c47d-482c-ad41-66abfe973772"
/>
This commit is contained in:
martmull
2026-04-16 11:47:44 +02:00
committed by GitHub
parent 64470baa1e
commit 381f3ba7d9
66 changed files with 2058 additions and 1537 deletions
@@ -1732,6 +1732,12 @@ type PublicWorkspaceData {
workspaceUrls: WorkspaceUrls!
}
type PublicWorkspaceDataSummary {
id: UUID!
logo: String
displayName: String
}
type NativeModelCapabilities {
webSearch: Boolean
twitterSearch: Boolean
@@ -3344,6 +3350,7 @@ type Query {
currentUser: User!
currentWorkspace: Workspace!
getPublicWorkspaceDataByDomain(origin: String): PublicWorkspaceData!
getPublicWorkspaceDataById(id: UUID!): PublicWorkspaceDataSummary!
getSSOIdentityProviders: [FindAvailableSSOIDP!]!
getConnectedImapSmtpCaldavAccount(id: UUID!): ConnectedImapSmtpCaldavAccount!
getAutoCompleteAddress(address: String!, token: String!, country: String, isFieldCity: Boolean): [AutocompleteResult!]!
@@ -4599,6 +4606,7 @@ input UpdateApplicationRegistrationVariableInput {
input UpdateApplicationRegistrationVariablePayload {
value: String
resetValue: Boolean
description: String
}
@@ -1430,6 +1430,13 @@ export interface PublicWorkspaceData {
__typename: 'PublicWorkspaceData'
}
export interface PublicWorkspaceDataSummary {
id: Scalars['UUID']
logo?: Scalars['String']
displayName?: Scalars['String']
__typename: 'PublicWorkspaceDataSummary'
}
export interface NativeModelCapabilities {
webSearch?: Scalars['Boolean']
twitterSearch?: Scalars['Boolean']
@@ -2905,6 +2912,7 @@ export interface Query {
currentUser: User
currentWorkspace: Workspace
getPublicWorkspaceDataByDomain: PublicWorkspaceData
getPublicWorkspaceDataById: PublicWorkspaceDataSummary
getSSOIdentityProviders: FindAvailableSSOIDP[]
getConnectedImapSmtpCaldavAccount: ConnectedImapSmtpCaldavAccount
getAutoCompleteAddress: AutocompleteResult[]
@@ -4680,6 +4688,14 @@ export interface PublicWorkspaceDataGenqlSelection{
__scalar?: boolean | number
}
export interface PublicWorkspaceDataSummaryGenqlSelection{
id?: boolean | number
logo?: boolean | number
displayName?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface NativeModelCapabilitiesGenqlSelection{
webSearch?: boolean | number
twitterSearch?: boolean | number
@@ -6274,6 +6290,7 @@ export interface QueryGenqlSelection{
currentUser?: UserGenqlSelection
currentWorkspace?: WorkspaceGenqlSelection
getPublicWorkspaceDataByDomain?: (PublicWorkspaceDataGenqlSelection & { __args?: {origin?: (Scalars['String'] | null)} })
getPublicWorkspaceDataById?: (PublicWorkspaceDataSummaryGenqlSelection & { __args: {id: Scalars['UUID']} })
getSSOIdentityProviders?: FindAvailableSSOIDPGenqlSelection
getConnectedImapSmtpCaldavAccount?: (ConnectedImapSmtpCaldavAccountGenqlSelection & { __args: {id: Scalars['UUID']} })
getAutoCompleteAddress?: (AutocompleteResultGenqlSelection & { __args: {address: Scalars['String'], token: Scalars['String'], country?: (Scalars['String'] | null), isFieldCity?: (Scalars['Boolean'] | null)} })
@@ -6859,7 +6876,7 @@ export interface CreateApplicationRegistrationVariableInput {applicationRegistra
export interface UpdateApplicationRegistrationVariableInput {id: Scalars['String'],update: UpdateApplicationRegistrationVariablePayload}
export interface UpdateApplicationRegistrationVariablePayload {value?: (Scalars['String'] | null),description?: (Scalars['String'] | null)}
export interface UpdateApplicationRegistrationVariablePayload {value?: (Scalars['String'] | null),resetValue?: (Scalars['Boolean'] | null),description?: (Scalars['String'] | null)}
export interface UpdateWorkspaceMemberSettingsInput {workspaceMemberId: Scalars['UUID'],update: Scalars['JSON']}
@@ -7850,6 +7867,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const PublicWorkspaceDataSummary_possibleTypes: string[] = ['PublicWorkspaceDataSummary']
export const isPublicWorkspaceDataSummary = (obj?: { __typename?: any } | null): obj is PublicWorkspaceDataSummary => {
if (!obj?.__typename) throw new Error('__typename is missing in "isPublicWorkspaceDataSummary"')
return PublicWorkspaceDataSummary_possibleTypes.includes(obj.__typename)
}
const NativeModelCapabilities_possibleTypes: string[] = ['NativeModelCapabilities']
export const isNativeModelCapabilities = (obj?: { __typename?: any } | null): obj is NativeModelCapabilities => {
if (!obj?.__typename) throw new Error('__typename is missing in "isNativeModelCapabilities"')
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -204,6 +204,14 @@ const SettingsApplicationRegistrationDetails = lazy(() =>
})),
);
const SettingsApplicationRegistrationConfigVariableDetail = lazy(() =>
import(
'~/pages/settings/applications/components/SettingsApplicationRegistrationConfigVariableDetail'
).then((module) => ({
default: module.SettingsApplicationRegistrationConfigVariableDetail,
})),
);
const SettingsAgentForm = lazy(() =>
import('~/pages/settings/ai/SettingsAgentForm').then((module) => ({
default: module.SettingsAgentForm,
@@ -744,6 +752,10 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.ApplicationLogicFunctionDetail}
element={<SettingsLogicFunctionDetail />}
/>
<Route
path={SettingsPath.ApplicationRegistrationConfigVariableDetails}
element={<SettingsApplicationRegistrationConfigVariableDetail />}
/>
</Route>
<Route
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const FIND_ONE_APPLICATION_SUMMARY = gql`
query FindOneApplicationSummary($universalIdentifier: UUID!) {
findOneApplication(universalIdentifier: $universalIdentifier) {
id
}
}
`;
@@ -174,7 +174,10 @@ export const useIdentifyActiveNavigationMenuItems = (): {
lastClickedNavigationMenuItemId,
objectMetadataItems,
views,
location,
currentPathWithSearch,
currentPath,
currentObjectMetadataItem,
isOnRecordShowPage,
contextStoreCurrentViewId,
]);
@@ -90,12 +90,7 @@ export const EmailThreadComposer = ({
disabled: !composerState.canSend,
},
];
}, [
isComposerOpen,
composerState.handleSend,
composerState.canSend,
setIsComposerOpen,
]);
}, [isComposerOpen, composerState, setIsComposerOpen]);
useEffect(() => {
if (!isInSidePanel) {
@@ -111,7 +106,7 @@ export const EmailThreadComposer = ({
if (isComposerOpen && composerState.canSend) {
composerState.handleSend();
}
}, [isComposerOpen, composerState.canSend, composerState.handleSend]);
}, [isComposerOpen, composerState]);
useHotkeysOnFocusedElement({
keys: ['ctrl+Enter,meta+Enter'],
@@ -203,19 +203,13 @@ export const useFieldsWidgetEditorGroupsData = ({
return {
groups: buildDefaultFieldsWidgetGroups({
fields: objectMetadataItem.fields,
objectNameSingular,
labelIdentifierFieldMetadataItemId:
labelIdentifierFieldMetadataItem?.id,
}),
ungroupedFields: [],
editorMode: 'grouped' as const,
};
}, [
objectMetadataItem,
objectNameSingular,
view,
labelIdentifierFieldMetadataItem,
]);
}, [objectMetadataItem, view, labelIdentifierFieldMetadataItem]);
return {
...result,
@@ -131,20 +131,13 @@ export const useFieldsWidgetGroups = ({
groups: filterDraftGroupsForDisplay(
buildDefaultFieldsWidgetGroups({
fields: objectMetadataItem.fields,
objectNameSingular,
labelIdentifierFieldMetadataItemId:
labelIdentifierFieldMetadataItem?.id,
}),
),
displayMode: 'grouped',
};
}, [
objectMetadataItem,
objectNameSingular,
labelIdentifierFieldMetadataItem,
view,
viewId,
]);
}, [objectMetadataItem, labelIdentifierFieldMetadataItem, view, viewId]);
return {
groups,
@@ -6,11 +6,9 @@ import { v4 as uuidv4 } from 'uuid';
export const buildDefaultFieldsWidgetGroups = ({
fields,
objectNameSingular,
labelIdentifierFieldMetadataItemId,
}: {
fields: FieldMetadataItem[];
objectNameSingular: string;
labelIdentifierFieldMetadataItemId: string | undefined;
}): FieldsWidgetGroup[] => {
const eligibleFields = fields.filter((field) =>
@@ -348,7 +348,9 @@ export const SettingsAdminAI = () => {
)
) : (
<SettingsEnterpriseFeatureGateCard
title={t`Enterprise feature`}
description={t`AI usage analytics across workspaces is available with an Enterprise key.`}
buttonTitle={t`Activate`}
/>
)}
</Section>
@@ -6,32 +6,34 @@ import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { getSettingsPath } from 'twenty-shared/utils';
import { useContext, useState } from 'react';
import { assertUnreachable, getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { H2Title, Status } from 'twenty-ui/display';
import {
H2Title,
IconChevronRight,
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import {
type ApplicationRegistrationFragmentFragment,
ApplicationRegistrationSourceType,
FindAllApplicationRegistrationsDocument,
} from '~/generated-metadata/graphql';
const StyledTableContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
margin-top: ${themeCssVariables.spacing[3]};
`;
const StyledTableHeaderRowContainer = styled.div`
margin-bottom: ${themeCssVariables.spacing[2]};
`;
const TABLE_GRID = '1fr 1fr 100px 80px';
const TABLE_GRID_MOBILE = '3fr 3fr 1fr 70px';
const TABLE_GRID = '1fr 100px 100px 40px';
const TABLE_GRID_MOBILE = '3fr 3fr 1fr 40px';
export const SettingsAdminApps = () => {
const [searchQuery, setSearchQuery] = useState('');
const { theme } = useContext(ThemeContext);
const { data } = useQuery(FindAllApplicationRegistrationsDocument);
@@ -51,6 +53,27 @@ export const SettingsAdminApps = () => {
);
});
const getFormattedSource = (
registration: ApplicationRegistrationFragmentFragment,
) => {
switch (registration.sourceType) {
case ApplicationRegistrationSourceType.TARBALL: {
return 'Tarball';
}
case ApplicationRegistrationSourceType.NPM: {
return 'NPM';
}
case ApplicationRegistrationSourceType.OAUTH_ONLY: {
return 'OAuth';
}
case ApplicationRegistrationSourceType.LOCAL: {
return 'Local';
}
default:
return assertUnreachable(registration.sourceType);
}
};
return (
<Section>
<H2Title
@@ -64,50 +87,43 @@ export const SettingsAdminApps = () => {
/>
<StyledTableContainer>
<Table>
<StyledTableHeaderRowContainer>
<TableRow
gridAutoColumns={TABLE_GRID}
mobileGridAutoColumns={TABLE_GRID_MOBILE}
>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Source`}</TableHeader>
<TableHeader>{t`Listed`}</TableHeader>
<TableHeader>{t`Featured`}</TableHeader>
</TableRow>
</StyledTableHeaderRowContainer>
<TableRow
gridAutoColumns={TABLE_GRID}
mobileGridAutoColumns={TABLE_GRID_MOBILE}
>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader align="right">{t`Source`}</TableHeader>
<TableHeader align="right">{t`Listed`}</TableHeader>
<TableHeader></TableHeader>
</TableRow>
<TableBody>
{filtered.map((registration) => (
<UndecoratedLink
<TableRow
key={registration.id}
to={getSettingsPath(
SettingsPath.AdminPanelApplicationRegistrationDetail,
{ applicationRegistrationId: registration.id },
)}
fullWidth
gridAutoColumns={TABLE_GRID}
mobileGridAutoColumns={TABLE_GRID_MOBILE}
isClickable
>
<TableRow
gridAutoColumns={TABLE_GRID}
mobileGridAutoColumns={TABLE_GRID_MOBILE}
isClickable
>
<TableCell>{registration.name}</TableCell>
<TableCell overflow="hidden">
{registration.sourcePackage ?? registration.sourceType}
</TableCell>
<TableCell>
<Status
color={registration.isListed ? 'green' : 'gray'}
text={registration.isListed ? t`Yes` : t`No`}
/>
</TableCell>
<TableCell>
<Status
color={registration.isFeatured ? 'yellow' : 'gray'}
text={registration.isFeatured ? t`Yes` : t`No`}
/>
</TableCell>
</TableRow>
</UndecoratedLink>
<TableCell color={themeCssVariables.font.color.primary}>
<OverflowingTextWithTooltip text={registration.name} />
</TableCell>
<TableCell overflow="hidden" align="right">
{getFormattedSource(registration)}
</TableCell>
<TableCell align="right">
{registration.isListed ? t`Yes` : t`No`}
</TableCell>
<TableCell align="right">
<IconChevronRight
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
@@ -1,7 +1,6 @@
import { currentUserState } from '@/auth/states/currentUserState';
import { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
import { IconArrowUp, IconLock } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
@@ -9,9 +8,13 @@ import { Card } from 'twenty-ui/layout';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const SettingsEnterpriseFeatureGateCard = ({
title,
description,
buttonTitle,
}: {
title: string;
description: string;
buttonTitle: string;
}) => {
const currentUser = useAtomStateValue(currentUserState);
const navigateSettings = useNavigateSettings();
@@ -22,12 +25,12 @@ export const SettingsEnterpriseFeatureGateCard = ({
<Card rounded>
<SettingsOptionCardContentButton
Icon={IconLock}
title={t`Enterprise feature`}
title={title}
description={description}
Button={
canAccessAdminPanel ? (
<Button
title={t`Activate`}
title={buttonTitle}
variant="primary"
accent="blue"
size="small"
@@ -1,7 +1,6 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
@@ -11,20 +10,13 @@ import {
import { Card } from 'twenty-ui/layout';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
type TableItem = {
export type TableItem = {
Icon?: IconComponent;
label: string;
value: string | number | React.ReactNode;
onClick?: () => void;
};
const StyledTableBody = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[2]} 0;
`;
type SettingsTableCardProps = {
items: TableItem[];
rounded?: boolean;
@@ -50,35 +42,27 @@ export const SettingsTableCard = ({
backgroundColor={themeCssVariables.background.secondary}
>
<Table>
<StyledTableBody>
{items.map((item, index) => (
<TableRow
key={index + item.label}
gridAutoColumns={gridAutoColumns}
height={`${themeCssVariables.spacing[7]}px`}
{items.map((item, index) => (
<TableRow key={index + item.label} gridAutoColumns={gridAutoColumns}>
<TableCell
align={labelAlign}
color={themeCssVariables.font.color.tertiary}
gap={themeCssVariables.spacing[2]}
overflow="hidden"
>
<TableCell
align={labelAlign}
color={themeCssVariables.font.color.tertiary}
height="auto"
gap={themeCssVariables.spacing[2]}
overflow="hidden"
>
{item.Icon && <item.Icon size={theme.icon.size.md} />}
<OverflowingTextWithTooltip text={item.label} />
</TableCell>
<TableCell
align={valueAlign}
color={themeCssVariables.font.color.primary}
height="auto"
onClick={item.onClick}
clickable={isDefined(item.onClick)}
>
{item.value}
</TableCell>
</TableRow>
))}
</StyledTableBody>
{item.Icon && <item.Icon size={theme.icon.size.md} />}
<OverflowingTextWithTooltip text={item.label} />
</TableCell>
<TableCell
align={valueAlign}
color={themeCssVariables.font.color.primary}
onClick={item.onClick}
clickable={isDefined(item.onClick)}
>
{item.value}
</TableCell>
</TableRow>
))}
</Table>
</Card>
);
@@ -14,6 +14,7 @@ import { SettingsPath } from 'twenty-shared/types';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { OrganizationAdornment } from '~/pages/settings/enterprise/components/OrganizationAdornment';
const StyledContent = styled.div`
padding-bottom: ${themeCssVariables.spacing[2]};
@@ -24,18 +25,6 @@ const StyledCardContainer = styled.div`
overflow: hidden;
`;
const StyledPillContainer = styled.span`
align-items: center;
background: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: 40px;
color: ${themeCssVariables.font.color.tertiary};
display: inline-flex;
font-weight: ${themeCssVariables.font.weight.medium};
gap: ${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]};
`;
type SettingsRolePermissionsObjectLevelRecordLevelSectionProps = {
objectMetadataItem: EnrichedObjectMetadataItem;
roleId: string;
@@ -57,12 +46,7 @@ export const SettingsRolePermissionsObjectLevelRecordLevelSection = ({
<H2Title
title={t`Record-level`}
description={t`Ability to filter the records a user can interact with`}
adornment={
<StyledPillContainer>
<IconLock size={12} />
{t`Organization`}
</StyledPillContainer>
}
adornment={<OrganizationAdornment />}
/>
<StyledCardContainer>
<Card rounded>
@@ -58,7 +58,7 @@ export const SidePanelComposeEmailPage = () => {
if (composerState.canSend) {
composerState.handleSend();
}
}, [composerState.canSend, composerState.handleSend]);
}, [composerState]);
useHotkeysOnFocusedElement({
keys: ['ctrl+Enter,meta+Enter'],
@@ -17,6 +17,7 @@ type SubMenuTopBarContainerProps = {
actionButton?: ReactNode;
className?: string;
links: BreadcrumbProps['links'];
tag?: JSX.Element;
};
const StyledContainer = styled.div`
@@ -25,10 +26,12 @@ const StyledContainer = styled.div`
width: 100%;
`;
const StyledTitle = styled.h3<{ reserveTitleSpace?: boolean }>`
const StyledTitle = styled.span<{ reserveTitleSpace?: boolean }>`
color: ${themeCssVariables.font.color.primary};
display: flex;
font-size: ${themeCssVariables.font.size.lg};
font-weight: ${themeCssVariables.font.weight.semiBold};
gap: ${themeCssVariables.spacing[2]};
line-height: 1.2;
margin: ${themeCssVariables.spacing[8]} ${themeCssVariables.spacing[8]}
${themeCssVariables.spacing[2]};
@@ -39,6 +42,7 @@ const StyledTitle = styled.h3<{ reserveTitleSpace?: boolean }>`
export const SubMenuTopBarContainer = ({
children,
title,
tag,
reserveTitleSpace,
actionButton,
className,
@@ -54,6 +58,7 @@ export const SubMenuTopBarContainer = ({
{(isDefined(title) || reserveTitleSpace === true) && (
<StyledTitle reserveTitleSpace={reserveTitleSpace}>
{title}
{tag}
</StyledTitle>
)}
{children}
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const GET_PUBLIC_WORKSPACE_DATA_BY_ID = gql`
query GetPublicWorkspaceDataById($id: UUID!) {
getPublicWorkspaceDataById(id: $id) {
id
displayName
logo
}
}
`;
@@ -1,14 +1,12 @@
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { type CalendarChannel } from '@/accounts/types/CalendarChannel';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { SettingsPath } from 'twenty-shared/types';
import { GET_MY_CALENDAR_CHANNELS } from '@/settings/accounts/graphql/queries/getMyCalendarChannels';
import { GET_MY_MESSAGE_CHANNELS } from '@/settings/accounts/graphql/queries/getMyMessageChannels';
import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts/states/settingsAccountsSelectedMessageChannelState';
import { useMutation, useQuery } from '@apollo/client/react';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
@@ -32,9 +30,6 @@ export const SettingsAccountsConfiguration = () => {
const [startChannelSyncMutation, { loading: isSubmitting }] = useMutation(
StartChannelSyncDocument,
);
const setSettingsAccountsSelectedMessageChannel = useSetAtomState(
settingsAccountsSelectedMessageChannelState,
);
const [currentStep, setCurrentStep] =
useState<SettingsAccountsConfigurationStep>(
@@ -298,11 +298,7 @@ export const SettingsAdminAiProviderDetail = () => {
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`AI`,
children: t`Admin Panel - AI`,
href: AI_ADMIN_PATH,
},
{
@@ -1,13 +1,27 @@
import { useParams } from 'react-router-dom';
import { useQuery } from '@apollo/client/react';
import { FindOneAdminApplicationRegistrationDocument } from '~/generated-metadata/graphql';
import type { ApplicationRegistrationData } from '~/pages/settings/applications/tabs/types/ApplicationRegistrationData';
import { useMutation, useQuery } from '@apollo/client/react';
import {
FindAllApplicationRegistrationsDocument,
FindOneAdminApplicationRegistrationDocument,
UpdateApplicationRegistrationDocument,
} from '~/generated-metadata/graphql';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { SettingsApplicationRegistrationContent } from '~/pages/settings/applications/components/SettingsApplicationRegistrationContent';
import { SettingsPath } from 'twenty-shared/types';
import { useLingui } from '@lingui/react/macro';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { APPLICATION_REGISTRATION_ADMIN_PATH } from '@/settings/admin-panel/apps/constants/ApplicationRegistrationAdminPath';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsAdminApplicationRegistrationDetailContent } from '~/pages/settings/admin-panel/SettingsAdminApplicationRegistrationDetailContent';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { IconArrowBarToDown } from 'twenty-ui/display';
import { Card, Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { styled } from '@linaria/react';
const StyledToggleContainer = styled.div`
display: flex;
margin-top: ${themeCssVariables.spacing[4]};
`;
export const SettingsAdminApplicationRegistrationDetail = () => {
const { t } = useLingui();
@@ -24,9 +38,17 @@ export const SettingsAdminApplicationRegistrationDetail = () => {
},
);
const registration = data?.findOneAdminApplicationRegistration as
| ApplicationRegistrationData
| undefined;
const [updateRegistration] = useMutation(
UpdateApplicationRegistrationDocument,
{
refetchQueries: [
FindOneAdminApplicationRegistrationDocument,
FindAllApplicationRegistrationsDocument,
],
},
);
const registration = data?.findOneAdminApplicationRegistration;
if (loading || !isDefined(registration)) {
return null;
@@ -41,20 +63,39 @@ export const SettingsAdminApplicationRegistrationDetail = () => {
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`App registrations`,
children: t`Admin Panel - Apps`,
href: APPLICATION_REGISTRATION_ADMIN_PATH,
},
{ children: registration.name },
]}
>
<SettingsApplicationRegistrationContent
registration={registration}
hasActiveInstalls={false}
/>
<SettingsPageContainer>
<SettingsAdminApplicationRegistrationDetailContent
registration={registration}
/>
<Section>
<StyledToggleContainer>
<Card rounded fullWidth>
<SettingsOptionCardContentToggle
Icon={IconArrowBarToDown}
title={t`Allow installation`}
description={t`Display this app in the NPM packages list`}
checked={registration.isListed}
onChange={(checked) =>
updateRegistration({
variables: {
input: {
id: registration.id,
update: { isListed: checked },
},
},
})
}
/>
</Card>
</StyledToggleContainer>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -0,0 +1,274 @@
import {
H2Title,
IconBox,
IconBrandDocker,
IconChartBar,
IconDownload,
IconStatusChange,
IconTag,
IconWorld,
IconGitBranch,
} from 'twenty-ui/display';
import { Trans, useLingui } from '@lingui/react/macro';
import {
SettingsTableCard,
type TableItem,
} from '@/settings/components/SettingsTableCard';
import {
type ApplicationRegistration,
ApplicationRegistrationSourceType,
ApplicationRegistrationTarballUrlDocument,
FindApplicationRegistrationStatsDocument,
FindOneApplicationSummaryDocument,
GetPublicWorkspaceDataByIdDocument,
} from '~/generated-metadata/graphql';
import { isNonEmptyString } from '@sniptt/guards';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useQuery } from '@apollo/client/react';
import {
AvatarOrIcon,
Chip,
ChipSize,
ChipVariant,
Tag,
} from 'twenty-ui/components';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { Section } from 'twenty-ui/layout';
import { SettingsPath } from 'twenty-shared/types';
import { SettingsApplicationRegistrationShareLinkButtons } from '~/pages/settings/applications/components/SettingsApplicationRegistrationShareLinkButtons';
const StyledSourceRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
`;
const StyledDownloadLink = styled.a`
color: ${themeCssVariables.font.color.tertiary};
cursor: pointer;
text-decoration: underline;
&:hover {
color: ${themeCssVariables.font.color.primary};
}
`;
const StyledGeneralContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
`;
export const SettingsAdminApplicationRegistrationDetailContent = ({
registration,
}: {
registration: ApplicationRegistration;
}) => {
const { t } = useLingui();
const applicationRegistrationId = registration.id;
const { data: tarballUrlData } = useQuery(
ApplicationRegistrationTarballUrlDocument,
{
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const { data: applicationSummaryData } = useQuery(
FindOneApplicationSummaryDocument,
{
variables: { universalIdentifier: registration.universalIdentifier },
skip: !registration.universalIdentifier,
},
);
const isApplicationInstalled = isDefined(
applicationSummaryData?.findOneApplication,
);
const { data: ownerWorkspaceData } = useQuery(
GetPublicWorkspaceDataByIdDocument,
{
variables: { id: registration.ownerWorkspaceId ?? '' },
skip: !registration.ownerWorkspaceId,
},
);
const { data: statsData } = useQuery(
FindApplicationRegistrationStatsDocument,
{
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const shareLink = getSettingsPath(SettingsPath.AvailableApplicationDetail, {
availableApplicationId: registration.universalIdentifier,
});
const ownerWorkspace = ownerWorkspaceData?.getPublicWorkspaceDataById;
const generateItems = () => {
const items: TableItem[] = [
{
Icon: IconWorld,
label: t`Universal ID`,
value: registration.universalIdentifier,
},
];
if (isDefined(ownerWorkspace?.displayName)) {
items.push({
Icon: IconTag,
label: t`Owner`,
value: (
<Chip
size={ChipSize.Large}
variant={ChipVariant.Highlighted}
clickable={false}
leftComponent={
<AvatarOrIcon
avatarType="rounded"
avatarUrl={ownerWorkspace?.logo ?? undefined}
/>
}
label={ownerWorkspace.displayName}
/>
),
});
}
switch (registration.sourceType) {
case ApplicationRegistrationSourceType.NPM:
items.push({
Icon: IconBox,
label: t`Package`,
value: 'NPM',
});
break;
case ApplicationRegistrationSourceType.TARBALL:
items.push({
Icon: IconBox,
label: t`Source`,
value: isNonEmptyString(
tarballUrlData?.applicationRegistrationTarballUrl,
) ? (
<StyledSourceRow>
<span>Tarball</span>
<StyledDownloadLink
href={tarballUrlData.applicationRegistrationTarballUrl}
download
>
<Trans>Download</Trans>
</StyledDownloadLink>
</StyledSourceRow>
) : (
'Tarball'
),
});
break;
case ApplicationRegistrationSourceType.LOCAL:
items.push({
Icon: IconBox,
label: t`Source`,
value: 'Local',
});
break;
case ApplicationRegistrationSourceType.OAUTH_ONLY:
items.push({
Icon: IconBox,
label: t`Source`,
value: 'OAuth',
});
break;
}
if (isNonEmptyString(registration.latestAvailableVersion)) {
items.push({
Icon: IconGitBranch,
label: t`Latest version`,
value: registration.latestAvailableVersion,
});
}
items.push({
Icon: IconDownload,
label: t`Installed`,
value: isApplicationInstalled ? (
<Tag color="green" text={t`Yes`} />
) : (
<Tag color="orange" text={t`No`} />
),
});
return items;
};
const stats = statsData?.findApplicationRegistrationStats;
const hasStats = (stats?.activeInstalls ?? 0) > 0;
const versionDistributionLabel =
stats?.versionDistribution
?.map(
(entry: { version: string; count: number }) =>
`${entry.version} (${entry.count})`,
)
.join(', ') || '—';
const statsItems = [
{
Icon: IconBrandDocker,
label: t`Active installs`,
value: stats?.activeInstalls ?? '—',
},
{
Icon: IconStatusChange,
label: t`Most installed version`,
value: stats?.mostInstalledVersion ?? '—',
},
{
Icon: IconChartBar,
label: t`Distribution`,
value: versionDistributionLabel,
},
];
return (
<>
<Section>
<H2Title title={t`General`} description={t`About your app`} />
<StyledGeneralContainer>
<SettingsTableCard
rounded
items={generateItems()}
gridAutoColumns="3fr 8fr"
/>
<SettingsApplicationRegistrationShareLinkButtons
shareLink={shareLink}
isInstalled={isApplicationInstalled}
universalIdentifier={registration.universalIdentifier}
isNpmSource={
registration.sourceType === ApplicationRegistrationSourceType.NPM
}
/>
</StyledGeneralContainer>
</Section>
{hasStats && (
<Section>
<H2Title
title={t`Install Stats`}
description={t`Usage across all workspaces on this server`}
/>
<SettingsTableCard
rounded
items={statsItems}
gridAutoColumns="200px 1fr"
/>
</Section>
)}
</>
);
};
@@ -128,11 +128,7 @@ export const SettingsAdminConfigVariableDetails = () => {
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Config Variables`,
children: t`Admin Panel - Config`,
href: getSettingsPath(
SettingsPath.AdminPanel,
undefined,
@@ -51,11 +51,7 @@ export const SettingsAdminIndicatorHealthStatus = () => {
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Health Status`,
children: t`Admin Panel - Health`,
href: getSettingsPath(SettingsPath.AdminPanelHealthStatus),
},
{ children: `${data?.getIndicatorHealthStatus?.label}` },
@@ -56,11 +56,11 @@ export const SettingsAdminQueueDetail = () => {
title={t`Queue: ${queueName}`}
links={[
{
children: t`Admin Panel`,
children: t`Other`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Health Status`,
children: t`Admin Panel - Health`,
href: getSettingsPath(SettingsPath.AdminPanelHealthStatus),
},
{
@@ -44,11 +44,7 @@ export const SettingsAdminWorkspaceChatThread = () => {
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`AI`,
children: t`Admin Panel - AI`,
href: AI_ADMIN_PATH,
},
{
@@ -169,11 +169,7 @@ export const SettingsAdminWorkspaceDetail = () => {
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`AI`,
children: t`Admin Panel - AI`,
href: AI_ADMIN_PATH,
},
{
@@ -50,7 +50,9 @@ export const SettingsAIUsageTab = () => {
}
/>
<SettingsEnterpriseFeatureGateCard
title={t`Enterprise feature`}
description={t`AI usage analytics is available with an Enterprise key.`}
buttonTitle={t`Activate`}
/>
</Section>
);
@@ -3,13 +3,10 @@ import { useQuery } from '@apollo/client/react';
import { useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
FindApplicationRegistrationStatsDocument,
FindOneApplicationRegistrationDocument,
} from '~/generated-metadata/graphql';
import { type ApplicationRegistrationData } from '~/pages/settings/applications/tabs/types/ApplicationRegistrationData';
import { FindOneApplicationRegistrationDocument } from '~/generated-metadata/graphql';
import { SettingsApplicationRegistrationContent } from '~/pages/settings/applications/components/SettingsApplicationRegistrationContent';
import { useLingui } from '@lingui/react/macro';
import { Tag } from 'twenty-ui/components';
export const SettingsApplicationRegistrationDetails = () => {
const { t } = useLingui();
@@ -23,21 +20,7 @@ export const SettingsApplicationRegistrationDetails = () => {
skip: !applicationRegistrationId,
});
const { data: statsData } = useQuery(
FindApplicationRegistrationStatsDocument,
{
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const registration = data?.findOneApplicationRegistration as
| ApplicationRegistrationData
| undefined;
const stats = statsData?.findApplicationRegistrationStats;
const hasActiveInstalls = (stats?.activeInstalls ?? 0) > 0;
const registration = data?.findOneApplicationRegistration;
if (loading || !isDefined(registration)) {
return null;
@@ -46,22 +29,25 @@ export const SettingsApplicationRegistrationDetails = () => {
return (
<SubMenuTopBarContainer
title={registration.name}
tag={<Tag text={t`Owner`} color={'gray'} />}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
children: t`Applications - Developer`,
href: getSettingsPath(
SettingsPath.Applications,
undefined,
undefined,
'developer',
),
},
{ children: registration.name },
]}
>
<SettingsApplicationRegistrationContent
registration={registration}
hasActiveInstalls={hasActiveInstalls}
/>
<SettingsApplicationRegistrationContent registration={registration} />
</SubMenuTopBarContainer>
);
};
@@ -9,13 +9,10 @@ import { useLingui } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconApps, IconCode, IconDownload } from 'twenty-ui/display';
import { useQuery } from '@apollo/client/react';
import {
FeatureFlagKey,
PermissionFlagType,
FindManyApplicationsDocument,
} from '~/generated-metadata/graphql';
import { SettingsApplicationsTable } from '~/pages/settings/applications/components/SettingsApplicationsTable';
import { SettingsApplicationsAvailableTab } from '~/pages/settings/applications/tabs/SettingsApplicationsAvailableTab';
import { SettingsApplicationsDeveloperTab } from '~/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab';
import { SettingsApplicationsInstalledTab } from '~/pages/settings/applications/tabs/SettingsApplicationsInstalledTab';
@@ -222,7 +222,6 @@ export const SettingsAvailableApplicationDetails = () => {
const sourceType = detail?.sourceType;
const isNpmApp = sourceType === ApplicationRegistrationSourceType.NPM;
const registrationId = detail?.id;
const sourcePackage = detail?.sourcePackage;
const sourcePackageUrl =
isNpmApp && detail?.sourcePackage
? `https://www.npmjs.com/package/${detail.sourcePackage}`
@@ -88,9 +88,11 @@ const StyledUnlistedBanner = styled.div`
color: ${themeCssVariables.font.color.secondary};
display: flex;
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.regular};
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[4]};
padding: ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[4]};
justify-content: center;
margin-bottom: ${themeCssVariables.spacing[8]};
padding: ${themeCssVariables.spacing[3]};
`;
export const SettingsApplicationDetailTitle = ({
@@ -104,7 +106,7 @@ export const SettingsApplicationDetailTitle = ({
{isUnlisted && (
<StyledUnlistedBanner>
<IconEyeOff size={16} />
{t`This application is not listed on the marketplace. It was shared via a direct link.`}
{t`Application not listed on the marketplace. It was shared via a direct link`}
</StyledUnlistedBanner>
)}
<StyledHeader>
@@ -0,0 +1,236 @@
import { useLingui } from '@lingui/react/macro';
import { useParams } from 'react-router-dom';
import { useState } from 'react';
import {
FindApplicationRegistrationVariablesDocument,
FindOneApplicationRegistrationDocument,
UpdateApplicationRegistrationVariableDocument,
} from '~/generated-metadata/graphql';
import { useMutation, useQuery } from '@apollo/client/react';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { isNonEmptyString } from '@sniptt/guards';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { Tag } from 'twenty-ui/components';
import { styled } from '@linaria/react';
import { H3Title, IconCheck, IconX } from 'twenty-ui/display';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { Button } from 'twenty-ui/input';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { TextInput } from '@/ui/input/components/TextInput';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SettingsApplicationRegistrationConfigVariableStatus } from '~/pages/settings/applications/components/SettingsApplicationRegistrationConfigVariableStatus';
import { Section } from 'twenty-ui/layout';
const RESET_VARIABLE_MODAL_ID =
'reset-application-registration-config-variable-modal';
const StyledRow = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledButtonContainer = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
min-width: 200px;
`;
export const SettingsApplicationRegistrationConfigVariableDetail = () => {
const { t } = useLingui();
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const { variableKey } = useParams();
const [value, setValue] = useState<string>('');
const [isEditing, setIsEditing] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const { openModal } = useModal();
const { applicationRegistrationId = '' } = useParams<{
applicationRegistrationId: string;
}>();
const { data: applicationRegistrationData } = useQuery(
FindOneApplicationRegistrationDocument,
{
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const registration =
applicationRegistrationData?.findOneApplicationRegistration;
const { data: variablesData } = useQuery(
FindApplicationRegistrationVariablesDocument,
{
variables: { applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const variable = (
variablesData?.findApplicationRegistrationVariables ?? []
).find((variable) => variable.key === variableKey);
const [updateVariable] = useMutation(
UpdateApplicationRegistrationVariableDocument,
{
refetchQueries: [FindApplicationRegistrationVariablesDocument],
},
);
if (!variable || !registration) {
return null;
}
const handleXButtonClick = () => {
if (!isEditing) {
openModal(RESET_VARIABLE_MODAL_ID);
return;
}
setValue('');
setIsEditing(false);
};
const handleSaveVariableValue = async ({
resetValue,
}: {
resetValue?: boolean;
}) => {
const variableKey = variable.key;
if (!isNonEmptyString(value) && !resetValue) {
return;
}
try {
setIsSubmitting(true);
await updateVariable({
variables: {
input: {
id: variable.id,
update: {
value,
resetValue,
},
},
},
});
enqueueSuccessSnackBar({
message: t`Variable ${variableKey} updated`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error updating variable`,
});
} finally {
setIsSubmitting(false);
setValue('');
setIsEditing(false);
}
};
const handleConfirmReset = async () => {
await handleSaveVariableValue({ resetValue: true });
};
return (
<SubMenuTopBarContainer
title={registration.name}
tag={<Tag text={t`Owner`} color={'gray'} />}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications - Developer`,
href: getSettingsPath(
SettingsPath.Applications,
undefined,
undefined,
'developer',
),
},
{
children: t`${registration.name} - Config`,
href: getSettingsPath(
SettingsPath.ApplicationRegistrationDetail,
{ applicationRegistrationId },
undefined,
'config',
),
},
{
children: variableKey,
},
]}
>
<SettingsPageContainer>
<Section>
<H3Title
title={
<>
{variable.key}
{variable.isRequired && (
<span style={{ color: 'red' }}> *</span>
)}
</>
}
description={variable.description}
/>
</Section>
<Section>
<StyledRow>
<TextInput
value={value}
placeholder={
variable.isFilled
? '••••••••••••••••••••••••'
: t`set-config-value`
}
onChange={(value) => {
setValue(value);
setIsEditing(true);
}}
fullWidth
/>
<StyledButtonContainer>
<Button
Icon={IconCheck}
variant="secondary"
onClick={() => handleSaveVariableValue({ resetValue: false })}
disabled={isSubmitting || !isEditing}
/>
<Button
Icon={IconX}
variant="secondary"
onClick={handleXButtonClick}
type="button"
disabled={isSubmitting || (!isEditing && !variable.isFilled)}
/>
<SettingsApplicationRegistrationConfigVariableStatus
variable={variable}
/>
</StyledButtonContainer>
</StyledRow>
</Section>
</SettingsPageContainer>
<ConfirmationModal
modalInstanceId={RESET_VARIABLE_MODAL_ID}
title={t`Reset variable`}
subtitle={t`Are you sure you want to reset this variable?`}
onConfirmClick={handleConfirmReset}
confirmButtonText={t`Reset`}
confirmButtonAccent="danger"
/>
</SubMenuTopBarContainer>
);
};
@@ -0,0 +1,19 @@
import { Status } from 'twenty-ui/display';
import { type ApplicationRegistrationVariable } from '~/generated-metadata/graphql';
import { useLingui } from '@lingui/react/macro';
export const SettingsApplicationRegistrationConfigVariableStatus = ({
variable,
}: {
variable: ApplicationRegistrationVariable;
}) => {
const { t } = useLingui();
return variable.isFilled ? (
<Status color="green" text={t`Configured`} />
) : variable.isRequired ? (
<Status color="red" text={t`Required`} />
) : (
<Status color="gray" text={t`Not set`} />
);
};
@@ -3,23 +3,27 @@ import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useLingui } from '@lingui/react/macro';
import { IconInfoCircle, IconKey, IconWorld } from 'twenty-ui/display';
import { type ApplicationRegistrationData } from '~/pages/settings/applications/tabs/types/ApplicationRegistrationData';
import {
IconInfoCircle,
IconKey,
IconSettings,
IconWorld,
} from 'twenty-ui/display';
import { SettingsApplicationRegistrationGeneralTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationGeneralTab';
import { SettingsApplicationRegistrationOAuthTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationOAuthTab';
import { SettingsApplicationRegistrationDistributionTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationDistributionTab';
import { type ApplicationRegistration } from '~/generated-metadata/graphql';
import { SettingsApplicationRegistrationConfigTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationConfigTab';
const REGISTRATION_DETAIL_TAB_LIST_ID =
'application-registration-detail-tab-list';
type SettingsApplicationRegistrationContentProps = {
registration: ApplicationRegistrationData;
hasActiveInstalls: boolean;
registration: ApplicationRegistration;
};
export const SettingsApplicationRegistrationContent = ({
registration,
hasActiveInstalls,
}: SettingsApplicationRegistrationContentProps) => {
const { t } = useLingui();
@@ -32,10 +36,17 @@ export const SettingsApplicationRegistrationContent = ({
{ id: 'general', title: t`General`, Icon: IconInfoCircle },
{ id: 'oauth', title: t`OAuth`, Icon: IconKey },
{ id: 'distribution', title: t`Distribution`, Icon: IconWorld },
{ id: 'config', title: t`Config`, Icon: IconSettings },
];
const renderActiveTabContent = () => {
switch (activeTabId) {
case 'config':
return (
<SettingsApplicationRegistrationConfigTab
registration={registration}
/>
);
case 'oauth':
return (
<SettingsApplicationRegistrationOAuthTab
@@ -53,7 +64,6 @@ export const SettingsApplicationRegistrationContent = ({
return (
<SettingsApplicationRegistrationGeneralTab
registration={registration}
hasActiveInstalls={hasActiveInstalls}
/>
);
}
@@ -0,0 +1,95 @@
import { styled } from '@linaria/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { useLingui } from '@lingui/react/macro';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
`;
const StyledLinkContainer = styled.div`
flex: 1;
margin-right: ${themeCssVariables.spacing[2]};
`;
type SettingsApplicationRegistrationRedirectURIsInputProps = {
updateRedirectUris: (newRedirectUris: string[]) => void;
redirectUris: string[];
};
type FormInput = {
redirectUri: string;
};
export const SettingsApplicationRegistrationRedirectURIsInput = ({
updateRedirectUris,
redirectUris,
}: SettingsApplicationRegistrationRedirectURIsInputProps) => {
const { t } = useLingui();
const validationSchema = (redirectUris: string[]) =>
z
.object({
redirectUri: z
.string()
.trim()
.min(1, 'URI is required')
.url(t`Please enter a valid URL`)
.refine(
(value) => !redirectUris.includes(value),
t`URI is already in redirect URIs list`,
),
})
.required();
const { reset, handleSubmit, control, formState } = useForm<FormInput>({
mode: 'onSubmit',
resolver: zodResolver(validationSchema(redirectUris)),
defaultValues: {
redirectUri: '',
},
});
const submit = handleSubmit((data) => {
updateRedirectUris([...redirectUris, data.redirectUri]);
});
const { isSubmitSuccessful } = formState;
useEffect(() => {
if (isSubmitSuccessful) {
reset();
}
}, [isSubmitSuccessful, reset]);
return (
<form onSubmit={submit}>
<StyledContainer>
<StyledLinkContainer>
<Controller
name="redirectUri"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<SettingsTextInput
instanceId="settings-application-registration-redirect-uris-input"
placeholder={t`https://example.com/callback`}
value={value}
onChange={onChange}
error={error?.message}
fullWidth
/>
)}
/>
</StyledLinkContainer>
<Button title={t`Add URI`} type="submit" />
</StyledContainer>
</form>
);
};
@@ -0,0 +1,74 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { IconX, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
type SettingsApplicationRegistrationRedirectURIsTableProps = {
redirectUris: string[];
updateRedirectUris: (redirectUris: string[]) => void;
};
const StyledTableContainer = styled.div`
margin-top: ${themeCssVariables.spacing[4]};
`;
const StyledTableBodyContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
`;
export const SettingsApplicationRegistrationRedirectURIsTable = ({
redirectUris,
updateRedirectUris,
}: SettingsApplicationRegistrationRedirectURIsTableProps) => {
return (
<>
{redirectUris.length > 0 && (
<StyledTableContainer>
<Table>
<TableRow
gridAutoColumns="1fr 20px"
mobileGridAutoColumns="1fr 20px"
>
<TableHeader>{t`URI`}</TableHeader>
<TableHeader></TableHeader>
</TableRow>
<StyledTableBodyContainer>
<TableBody>
{redirectUris.map((redirectUri) => (
<TableRow
key={redirectUri}
gridAutoColumns="1fr 20px"
mobileGridAutoColumns="1fr 20px"
>
<TableCell color={themeCssVariables.font.color.primary}>
<OverflowingTextWithTooltip text={redirectUri} />
</TableCell>
<TableCell align="right">
<IconButton
onClick={() => {
updateRedirectUris(
redirectUris.filter((uri) => uri !== redirectUri),
);
}}
variant="tertiary"
size="small"
Icon={IconX}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</StyledTableBodyContainer>
</Table>
</StyledTableContainer>
)}
</>
);
};
@@ -0,0 +1,86 @@
import { Button } from 'twenty-ui/input';
import {
IconArrowUpRight,
IconCopy,
IconDownload,
IconInfoCircle,
} from 'twenty-ui/display';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { useLingui } from '@lingui/react/macro';
import { useInstallMarketplaceApp } from '@/marketplace/hooks/useInstallMarketplaceApp';
import { isDefined } from 'twenty-shared/utils';
const StyledButtonGroup = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
export const SettingsApplicationRegistrationShareLinkButtons = ({
shareLink,
isInstalled,
universalIdentifier,
isNpmSource = false,
withCopyButton = false,
}: {
shareLink: string;
isInstalled?: boolean;
universalIdentifier?: string;
isNpmSource?: boolean;
withCopyButton?: boolean;
}) => {
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const { install, isInstalling } = useInstallMarketplaceApp();
const installable =
isDefined(isInstalled) && isDefined(universalIdentifier) && !isInstalled;
const handleInstall = async () => {
if (installable) {
await install({
universalIdentifier,
});
}
};
return (
<StyledButtonGroup>
{installable && (
<Button
Icon={IconDownload}
title={isInstalling ? t`Installing...` : t`Install`}
variant={'secondary'}
onClick={handleInstall}
disabled={isInstalling}
/>
)}
{withCopyButton && (
<Button
Icon={IconCopy}
title={t`Copy sharing link`}
variant="secondary"
disabled={!shareLink}
onClick={async () => {
if (shareLink) {
await copyToClipboard(
`${window.location.origin}${shareLink}`,
t`Sharing link copied to clipboard`,
);
}
}}
/>
)}
<Button
Icon={isNpmSource ? IconArrowUpRight : IconInfoCircle}
title={isNpmSource ? t`See on marketplace` : t`See app page`}
variant="secondary"
disabled={!shareLink}
to={shareLink}
/>
</StyledButtonGroup>
);
};
@@ -31,7 +31,7 @@ export const SettingsApplicationTableRow = ({
to={link}
>
<TableCell
color={themeCssVariables.font.color.secondary}
color={themeCssVariables.font.color.primary}
gap={themeCssVariables.spacing[2]}
minWidth="0"
overflow="hidden"
@@ -18,10 +18,6 @@ import { SearchInput } from 'twenty-ui/input';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { ApplicationRegistrationSourceType } from '~/generated-metadata/graphql';
const StyledTableContainer = styled.div`
margin-top: ${themeCssVariables.spacing[3]};
`;
const StyledTableRowsContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
padding: ${themeCssVariables.spacing[2]} 0;
@@ -63,51 +59,49 @@ export const SettingsApplicationsTable = ({
onChange={setSearchTerm}
/>
</StyledSearchInputContainer>
<StyledTableContainer>
<Table>
<TableRow
gridTemplateColumns={APPLICATION_TABLE_ROW_GRID_TEMPLATE_COLUMNS}
>
<TableHeader> {t`Name`}</TableHeader>
<TableHeader> {t`Description`}</TableHeader>
<TableHeader> {''}</TableHeader>
<TableHeader />
</TableRow>
<StyledTableRowsContainer>
{filteredApplications.map((application) => {
const isNpmApp =
application.applicationRegistration?.sourceType ===
ApplicationRegistrationSourceType.NPM;
<Table>
<TableRow
gridTemplateColumns={APPLICATION_TABLE_ROW_GRID_TEMPLATE_COLUMNS}
>
<TableHeader> {t`Name`}</TableHeader>
<TableHeader> {t`Description`}</TableHeader>
<TableHeader> {''}</TableHeader>
<TableHeader />
</TableRow>
<StyledTableRowsContainer>
{filteredApplications.map((application) => {
const isNpmApp =
application.applicationRegistration?.sourceType ===
ApplicationRegistrationSourceType.NPM;
const latestVersion =
application.applicationRegistration?.latestAvailableVersion;
const latestVersion =
application.applicationRegistration?.latestAvailableVersion;
const hasUpdate =
isNpmApp &&
isDefined(latestVersion) &&
isDefined(application.version) &&
isNewerSemver(latestVersion, application.version);
const hasUpdate =
isNpmApp &&
isDefined(latestVersion) &&
isDefined(application.version) &&
isNewerSemver(latestVersion, application.version);
return (
<SettingsApplicationTableRow
key={application.id}
application={application}
hasUpdate={hasUpdate}
action={
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
link={getSettingsPath(SettingsPath.ApplicationDetail, {
applicationId: application.id,
})}
/>
);
})}
</StyledTableRowsContainer>
</Table>
</StyledTableContainer>
return (
<SettingsApplicationTableRow
key={application.id}
application={application}
hasUpdate={hasUpdate}
action={
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
link={getSettingsPath(SettingsPath.ApplicationDetail, {
applicationId: application.id,
})}
/>
);
})}
</StyledTableRowsContainer>
</Table>
</Section>
);
};
@@ -109,7 +109,7 @@ export const SettingsApplicationDetailContentTab = ({
return null;
}
const { logicFunctions, agents } = application;
const { logicFunctions } = application;
const shouldDisplayLogicFunctions =
isDefined(logicFunctions) && logicFunctions?.length > 0;
@@ -0,0 +1,126 @@
import type { ApplicationRegistrationData } from '~/pages/settings/applications/tabs/types/ApplicationRegistrationData';
import { useQuery } from '@apollo/client/react';
import {
type ApplicationRegistrationVariable,
FindApplicationRegistrationVariablesDocument,
} from '~/generated-metadata/graphql';
import { Section } from 'twenty-ui/layout';
import {
H2Title,
IconChevronRight,
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { Table } from '@/ui/layout/table/components/Table';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { SettingsApplicationRegistrationConfigVariableStatus } from '~/pages/settings/applications/components/SettingsApplicationRegistrationConfigVariableStatus';
const StyledTableBodyContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
`;
export const SettingsApplicationRegistrationConfigTab = ({
registration,
}: {
registration: ApplicationRegistrationData;
}) => {
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
const applicationRegistrationId = registration.id;
const { data: variablesData } = useQuery(
FindApplicationRegistrationVariablesDocument,
{
variables: { applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const variables: ApplicationRegistrationVariable[] =
variablesData?.findApplicationRegistrationVariables ?? [];
return (
variables.length > 0 && (
<Section>
<H2Title
title={t`Server Variables`}
description={t`Server variables are applied to all workspace installations.`}
/>
<Table>
<TableRow gridAutoColumns="4fr 3fr 3fr 1fr">
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Description`}</TableHeader>
<TableHeader align="right">{t`Status`}</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
<StyledTableBodyContainer>
<TableBody>
{variables.map((variable) => (
<TableRow
key={variable.key}
gridAutoColumns="4fr 3fr 3fr 1fr"
to={getSettingsPath(
SettingsPath.ApplicationRegistrationConfigVariableDetails,
{
applicationRegistrationId,
variableKey: variable.key,
},
)}
>
<TableCell
color={theme.font.color.primary}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
clickable
>
<OverflowingTextWithTooltip text={variable.key} />
{variable.isRequired && (
<span style={{ color: 'red' }}> *</span>
)}
</TableCell>
<TableCell
color={theme.font.color.secondary}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
clickable
>
<OverflowingTextWithTooltip text={variable.description} />
</TableCell>
<TableCell
color={theme.font.color.secondary}
align="right"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
clickable
>
<SettingsApplicationRegistrationConfigVariableStatus
variable={variable}
/>
</TableCell>
<TableCell align="right" color={theme.font.color.secondary}>
<IconChevronRight
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</StyledTableBodyContainer>
</Table>
</Section>
)
);
};
@@ -1,36 +1,13 @@
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import {
H2Title,
IconChartBar,
IconCopy,
IconDownload,
IconExternalLink,
IconTag,
} from 'twenty-ui/display';
import { CommandBlock, H2Title, IconCopy } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { Section } from 'twenty-ui/layout';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
ApplicationRegistrationSourceType,
FindApplicationRegistrationStatsDocument,
} from '~/generated-metadata/graphql';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { ApplicationRegistrationSourceType } from '~/generated-metadata/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { type ApplicationRegistrationData } from '~/pages/settings/applications/tabs/types/ApplicationRegistrationData';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SettingsEnterpriseFeatureGateCard } from '@/settings/components/SettingsEnterpriseFeatureGateCard';
const StyledButtonGroup = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
padding-top: ${themeCssVariables.spacing[2]};
`;
import { SettingsApplicationRegistrationShareLinkButtons } from '~/pages/settings/applications/components/SettingsApplicationRegistrationShareLinkButtons';
export const SettingsApplicationRegistrationDistributionTab = ({
registration,
@@ -39,135 +16,61 @@ export const SettingsApplicationRegistrationDistributionTab = ({
}) => {
const { t } = useLingui();
const applicationRegistrationId = registration.id;
const { copyToClipboard } = useCopyToClipboard();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const hasEnterpriseAccess = currentWorkspace?.hasValidEnterpriseKey === true;
const isNpmSource =
registration.sourceType === ApplicationRegistrationSourceType.NPM;
const isTarballSource =
registration.sourceType === ApplicationRegistrationSourceType.TARBALL;
const { data: statsData } = useQuery(
FindApplicationRegistrationStatsDocument,
{
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const shareLink = getSettingsPath(SettingsPath.AvailableApplicationDetail, {
availableApplicationId: registration.universalIdentifier,
});
const stats = statsData?.findApplicationRegistrationStats;
const hasStats = (stats?.activeInstalls ?? 0) > 0;
const versionDistributionLabel =
stats?.versionDistribution
?.map(
(entry: { version: string; count: number }) =>
`${entry.version} (${entry.count})`,
)
.join(', ') || '—';
const statsItems = [
{
Icon: IconDownload,
label: t`Active installs`,
value: stats?.activeInstalls ?? '—',
},
{
Icon: IconTag,
label: t`Most installed version`,
value: stats?.mostInstalledVersion ?? '—',
},
{
Icon: IconChartBar,
label: t`Distribution`,
value: versionDistributionLabel,
},
];
const publishCommands = ['yarn twenty publish'];
return (
<>
{isNpmSource && (
<Section>
<H2Title
title={t`Marketplace Listing`}
description={t`This app is listed on the marketplace because it is published to npm.`}
<Section>
<H2Title
title={t`Public`}
description={t`Publish your app to the marketplace so others can install it`}
/>
{isNpmSource && (
<SettingsApplicationRegistrationShareLinkButtons
shareLink={shareLink}
isNpmSource
withCopyButton
/>
<Card rounded>
<SettingsOptionCardContentToggle
title={t`Featured`}
description={t`Featured apps are curated. Open a PR to request featured status.`}
checked={registration.isFeatured}
onChange={() => {}}
disabled
/>
</Card>
<StyledButtonGroup>
<Button
Icon={IconExternalLink}
title={t`View marketplace page`}
variant="secondary"
to={shareLink}
/>
</StyledButtonGroup>
</Section>
)}
{isTarballSource &&
(!hasEnterpriseAccess ? (
<SettingsEnterpriseFeatureGateCard
description={t`Upgrade to Enterprise to share private applications.`}
/>
) : (
<Section>
<H2Title
title={t`Share`}
description={t`Share this link with other workspaces on this server to let them install this application.`}
/>
<StyledButtonGroup>
)}
{isTarballSource && (
<CommandBlock
commands={publishCommands}
button={
<Button
Icon={IconCopy}
title={t`Copy share link`}
variant="secondary"
disabled={!shareLink}
onClick={async () => {
if (shareLink) {
await copyToClipboard(
shareLink,
t`Share link copied to clipboard`,
);
}
onClick={() => {
copyToClipboard(
publishCommands.join('\n'),
t`Command copied to clipboard`,
);
}}
ariaLabel={t`Copy command`}
Icon={IconCopy}
/>
<Button
Icon={IconExternalLink}
title={t`View private application page`}
variant="secondary"
to={shareLink}
/>
</StyledButtonGroup>
</Section>
))}
{hasStats && (
}
/>
)}
</Section>
{isTarballSource && (
<Section>
<H2Title
title={t`Install Stats`}
description={t`Usage across all workspaces on this server`}
title={t`Private`}
description={t`Share your app to other workspaces without pushing it on the marketplace`}
/>
<SettingsTableCard
rounded
items={statsItems}
gridAutoColumns="3fr 8fr"
<SettingsApplicationRegistrationShareLinkButtons
shareLink={shareLink}
withCopyButton
/>
</Section>
)}
@@ -1,42 +1,30 @@
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useInstallMarketplaceApp } from '~/modules/marketplace/hooks/useInstallMarketplaceApp';
import { useMutation, useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
H1Title,
H1TitleFontColor,
H2Title,
IconArrowRight,
IconBox,
IconCheck,
IconDownload,
IconTag,
IconTextCaption,
IconShare,
IconTrash,
IconWorld,
Status,
AppTooltip,
TooltipDelay,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section, SectionAlignment, SectionFontColor } from 'twenty-ui/layout';
import {
ApplicationRegistrationSourceType,
ApplicationRegistrationTarballUrlDocument,
type ApplicationRegistration,
DeleteApplicationRegistrationDocument,
FindApplicationRegistrationVariablesDocument,
FindApplicationRegistrationStatsDocument,
FindManyApplicationRegistrationsDocument,
FindManyApplicationsDocument,
TransferApplicationRegistrationOwnershipDocument,
UninstallApplicationDocument,
UpdateApplicationRegistrationVariableDocument,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -46,114 +34,50 @@ import {
StyledAppModalSection,
StyledAppModalTitle,
} from '~/pages/settings/applications/components/SettingsAppModalLayout';
import { type ApplicationRegistrationData } from '~/pages/settings/applications/tabs/types/ApplicationRegistrationData';
import { SettingsAdminApplicationRegistrationDetailContent } from '~/pages/settings/admin-panel/SettingsAdminApplicationRegistrationDetailContent';
import { isDefined } from 'twenty-shared/utils';
const DELETE_REGISTRATION_MODAL_ID = 'delete-application-registration-modal';
const TRANSFER_OWNERSHIP_MODAL_ID =
'transfer-application-registration-ownership-modal';
const UNINSTALL_APPLICATION_MODAL_ID =
'uninstall-application-from-registration-modal';
const StyledVariableRow = styled.div`
align-items: center;
border-bottom: 1px solid ${themeCssVariables.border.color.light};
display: flex;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[2]} 0;
`;
const StyledVariableInfo = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: ${themeCssVariables.spacing['0.5']};
`;
const StyledVariableKey = styled.span`
color: ${themeCssVariables.font.color.primary};
font-family: monospace;
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledVariableDescription = styled.span`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
`;
const DELETE_REGISTRATION_BUTTON_ID = 'delete-registration-button';
const StyledDangerButtonGroup = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledSourceRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledDownloadLink = styled.a`
color: ${themeCssVariables.font.color.secondary};
cursor: pointer;
text-decoration: underline;
&:hover {
color: ${themeCssVariables.font.color.primary};
}
`;
const StyledButtonGroup = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
padding-top: ${themeCssVariables.spacing[2]};
`;
type ServerVariable = {
id: string;
key: string;
description: string;
isSecret: boolean;
isRequired: boolean;
isFilled: boolean;
};
export const SettingsApplicationRegistrationGeneralTab = ({
registration,
hasActiveInstalls,
}: {
registration: ApplicationRegistrationData;
hasActiveInstalls: boolean;
registration: ApplicationRegistration;
}) => {
const { t } = useLingui();
const navigate = useNavigateSettings();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { openModal, closeModal } = useModal();
const [isInstalling, setIsInstalling] = useState(false);
const [isUninstalling, setIsUninstalling] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isTransferring, setIsTransferring] = useState(false);
const [transferSubdomain, setTransferSubdomain] = useState('');
const [variableValues, setVariableValues] = useState<Record<string, string>>(
{},
);
const applicationRegistrationId = registration.id;
const { data: variablesData } = useQuery(
FindApplicationRegistrationVariablesDocument,
{
variables: { applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const { data: tarballUrlData } = useQuery(
ApplicationRegistrationTarballUrlDocument,
const { data: statsData } = useQuery(
FindApplicationRegistrationStatsDocument,
{
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const stats = statsData?.findApplicationRegistrationStats;
const hasActiveInstalls =
!isDefined(stats) || (stats.activeInstalls ?? 0) > 0;
const [deleteRegistration] = useMutation(
DeleteApplicationRegistrationDocument,
{
@@ -161,13 +85,6 @@ export const SettingsApplicationRegistrationGeneralTab = ({
},
);
const [updateVariable] = useMutation(
UpdateApplicationRegistrationVariableDocument,
{
refetchQueries: [FindApplicationRegistrationVariablesDocument],
},
);
const [transferOwnership] = useMutation(
TransferApplicationRegistrationOwnershipDocument,
{
@@ -175,74 +92,6 @@ export const SettingsApplicationRegistrationGeneralTab = ({
},
);
const { install } = useInstallMarketplaceApp();
const [uninstallApplication] = useMutation(UninstallApplicationDocument);
const { data: applicationsData, refetch: refetchApplications } = useQuery(
FindManyApplicationsDocument,
);
const variables: ServerVariable[] =
variablesData?.findApplicationRegistrationVariables ?? [];
const isNpmSource =
registration.sourceType === ApplicationRegistrationSourceType.NPM;
const installedApp = (applicationsData?.findManyApplications ?? []).find(
(application) =>
application.universalIdentifier === registration.universalIdentifier,
);
const isInstalledOnWorkspace = isDefined(installedApp);
const installedAppUrl = isInstalledOnWorkspace
? getSettingsPath(SettingsPath.ApplicationDetail, {
applicationId: installedApp.id,
})
: undefined;
const handleInstallOnWorkspace = async () => {
setIsInstalling(true);
try {
const success = await install({
universalIdentifier: registration.universalIdentifier,
});
if (success) {
await refetchApplications();
enqueueSuccessSnackBar({
message: t`App installed on this workspace`,
});
}
} catch {
enqueueErrorSnackBar({
message: t`Error installing app`,
});
} finally {
setIsInstalling(false);
}
};
const handleUninstallFromWorkspace = async () => {
setIsUninstalling(true);
try {
await uninstallApplication({
variables: {
universalIdentifier: registration.universalIdentifier,
},
});
await refetchApplications();
enqueueSuccessSnackBar({
message: t`Application uninstalled from this workspace`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error uninstalling application`,
});
} finally {
setIsUninstalling(false);
}
};
const handleDelete = async () => {
setIsLoading(true);
try {
@@ -288,249 +137,43 @@ export const SettingsApplicationRegistrationGeneralTab = ({
}
};
const handleSaveVariableValue = async (variable: ServerVariable) => {
const value = variableValues[variable.id];
const variableKey = variable.key;
if (!isNonEmptyString(value)) {
return;
}
try {
await updateVariable({
variables: {
input: {
id: variable.id,
update: {
value,
},
},
},
});
setVariableValues((previous) => {
const next = { ...previous };
delete next[variable.id];
return next;
});
enqueueSuccessSnackBar({
message: t`Variable ${variableKey} updated`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error updating variable`,
});
}
};
const confirmationValue = t`yes`;
const generalItems = [
{
Icon: IconTag,
label: t`Name`,
value: registration.name,
},
{
Icon: IconWorld,
label: t`Universal ID`,
value: registration.universalIdentifier,
},
...(isNpmSource && isNonEmptyString(registration.sourcePackage)
? [
{
Icon: IconBox,
label: t`Package`,
value: registration.sourcePackage,
},
]
: registration.sourceType === ApplicationRegistrationSourceType.TARBALL
? [
{
Icon: IconBox,
label: t`Source`,
value: isNonEmptyString(
tarballUrlData?.applicationRegistrationTarballUrl,
) ? (
<StyledSourceRow>
<span>
<Trans>Tarball upload</Trans>
</span>
<StyledDownloadLink
href={tarballUrlData.applicationRegistrationTarballUrl}
download
>
<Trans>Download</Trans>
</StyledDownloadLink>
</StyledSourceRow>
) : (
t`Tarball upload`
),
},
]
: registration.sourceType === ApplicationRegistrationSourceType.LOCAL
? [
{
Icon: IconBox,
label: t`Source`,
value: t`Local development`,
},
]
: []),
];
return (
<>
<Section>
<H2Title
title={t`General`}
description={t`Name and description are managed via your app manifest (CLI)`}
/>
<SettingsTableCard
rounded
items={generalItems}
gridAutoColumns="3fr 8fr"
/>
</Section>
<Section>
<H2Title
title={t`Installation`}
description={
isInstalledOnWorkspace
? t`This app is installed on the current workspace`
: t`Install this app on the current workspace`
}
/>
{isInstalledOnWorkspace ? (
<StyledButtonGroup>
<Button
Icon={IconArrowRight}
title={t`View installed app`}
variant="primary"
accent="blue"
to={installedAppUrl}
/>
<Button
Icon={IconTrash}
title={t`Uninstall`}
variant="secondary"
accent="danger"
onClick={() => openModal(UNINSTALL_APPLICATION_MODAL_ID)}
/>
</StyledButtonGroup>
) : (
<Button
Icon={IconDownload}
title={
isInstalling ? t`Installing...` : t`Install on this workspace`
}
variant="secondary"
accent="blue"
disabled={isInstalling}
onClick={handleInstallOnWorkspace}
/>
)}
</Section>
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalInstanceId={UNINSTALL_APPLICATION_MODAL_ID}
title={t`Uninstall Application?`}
subtitle={
<Trans>
This will remove the application and all its data from this
workspace. Please type {`"${confirmationValue}"`} to confirm.
</Trans>
}
onConfirmClick={handleUninstallFromWorkspace}
confirmButtonText={t`Uninstall`}
loading={isUninstalling}
<SettingsAdminApplicationRegistrationDetailContent
registration={registration}
/>
{variables.length > 0 && (
<Section>
<H2Title
title={t`Server Variables`}
description={t`Variables declared by the app manifest. Fill in values here — they apply to all workspace installations.`}
/>
{variables.map((variable) => (
<StyledVariableRow key={variable.id}>
<StyledVariableInfo>
<StyledVariableKey>
{variable.key}
{variable.isRequired && (
<span style={{ color: 'red' }}> *</span>
)}
</StyledVariableKey>
{isNonEmptyString(variable.description) && (
<StyledVariableDescription>
{variable.description}
</StyledVariableDescription>
)}
</StyledVariableInfo>
{variable.isFilled &&
!isNonEmptyString(variableValues[variable.id]) && (
<Status color="green" text={t`Configured`} />
)}
{!variable.isFilled &&
!isNonEmptyString(variableValues[variable.id]) && (
<Status
color={variable.isRequired ? 'red' : 'gray'}
text={variable.isRequired ? t`Required` : t`Not set`}
/>
)}
<SettingsTextInput
instanceId={`var-${variable.id}`}
value={variableValues[variable.id] ?? ''}
onChange={(value) =>
setVariableValues((previous) => ({
...previous,
[variable.id]: value,
}))
}
placeholder={
variable.isSecret ? t`Enter secret value` : t`Enter value`
}
fullWidth
/>
<Button
Icon={IconCheck}
variant="secondary"
size="small"
disabled={!isNonEmptyString(variableValues[variable.id])}
onClick={() => handleSaveVariableValue(variable)}
/>
</StyledVariableRow>
))}
</Section>
)}
<Section>
<H2Title
title={t`Danger zone`}
description={
hasActiveInstalls
? t`Uninstall this app from all workspaces before deleting it`
: t`Delete or transfer this app registration`
}
description={t`Delete or transfer this app registration`}
/>
<StyledDangerButtonGroup>
<Button
id={DELETE_REGISTRATION_BUTTON_ID}
accent="danger"
variant="secondary"
title={t`Delete`}
title={t`Delete app`}
Icon={IconTrash}
disabled={hasActiveInstalls}
onClick={() => openModal(DELETE_REGISTRATION_MODAL_ID)}
/>
{hasActiveInstalls && (
<AppTooltip
anchorSelect={`#${DELETE_REGISTRATION_BUTTON_ID}`}
content={t`Uninstall this app from all workspaces before deleting it`}
noArrow
place="bottom"
positionStrategy="fixed"
delay={TooltipDelay.shortDelay}
/>
)}
<Button
accent="default"
variant="secondary"
title={t`Transfer ownership`}
Icon={IconArrowRight}
Icon={IconShare}
onClick={() => openModal(TRANSFER_OWNERSHIP_MODAL_ID)}
/>
</StyledDangerButtonGroup>
@@ -1,7 +1,6 @@
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
@@ -10,59 +9,25 @@ import { styled } from '@linaria/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import {
H2Title,
IconKey,
IconRefresh,
IconShield,
IconTrash,
} from 'twenty-ui/display';
import { H2Title, IconKey, IconRefresh, IconShield } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
FindManyApplicationRegistrationsDocument,
FindOneApplicationRegistrationDocument,
RotateApplicationRegistrationClientSecretDocument,
UpdateApplicationRegistrationDocument,
} from '~/generated-metadata/graphql';
import { applicationRegistrationClientSecretFamilyState } from '~/pages/settings/applications/states/applicationRegistrationClientSecretFamilyState';
import { type ApplicationRegistrationData } from '~/pages/settings/applications/tabs/types/ApplicationRegistrationData';
import { isValidUrl } from 'twenty-shared/utils';
import { SettingsApplicationRegistrationRedirectURIsInput } from '~/pages/settings/applications/components/SettingsApplicationRegistrationRedirectURIsInput';
import { SettingsApplicationRegistrationRedirectURIsTable } from '~/pages/settings/applications/components/SettingsApplicationRegistrationRedirectURIsTable';
const ROTATE_SECRET_MODAL_ID = 'rotate-application-registration-secret-modal';
const StyledInputContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
gap: ${themeCssVariables.spacing[2]};
width: 100%;
`;
const StyledRedirectUriRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[1]} 0;
`;
const StyledRedirectUriValue = styled.span`
color: ${themeCssVariables.font.color.primary};
font-family: monospace;
word-break: break-all;
`;
const StyledRotateContainer = styled.div`
padding-top: ${themeCssVariables.spacing[2]};
`;
const StyledSaveContainer = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
padding-top: ${themeCssVariables.spacing[2]};
`;
export const SettingsApplicationRegistrationOAuthTab = ({
registration,
}: {
@@ -84,40 +49,31 @@ export const SettingsApplicationRegistrationOAuthTab = ({
const [formRedirectUris, setFormRedirectUris] = useState<string[]>(
registration.oAuthRedirectUris ?? [],
);
const [newRedirectUri, setNewRedirectUri] = useState('');
const [hasChanges, setHasChanges] = useState(false);
const [updateRegistration] = useMutation(
UpdateApplicationRegistrationDocument,
{
refetchQueries: [
FindOneApplicationRegistrationDocument,
FindManyApplicationRegistrationsDocument,
],
},
);
const [rotateSecret] = useMutation(
RotateApplicationRegistrationClientSecretDocument,
);
const markDirty = () => setHasChanges(true);
const handleSave = async () => {
const handleSave = async (newFormRedirectUris: string[]) => {
setIsLoading(true);
try {
await updateRegistration({
variables: {
input: {
id: applicationRegistrationId,
update: {
oAuthRedirectUris: formRedirectUris,
oAuthRedirectUris: newFormRedirectUris,
},
},
},
});
setHasChanges(false);
enqueueSuccessSnackBar({ message: t`Redirect URIs updated` });
setFormRedirectUris(newFormRedirectUris);
} catch {
enqueueErrorSnackBar({ message: t`Error updating redirect URIs` });
} finally {
@@ -125,11 +81,6 @@ export const SettingsApplicationRegistrationOAuthTab = ({
}
};
const handleCancel = () => {
setFormRedirectUris(registration.oAuthRedirectUris ?? []);
setHasChanges(false);
};
const handleRotateSecret = async () => {
setIsLoading(true);
try {
@@ -154,38 +105,8 @@ export const SettingsApplicationRegistrationOAuthTab = ({
}
};
const handleAddRedirectUri = () => {
const trimmed = newRedirectUri.trim();
if (!trimmed) {
return;
}
if (!isValidUrl(trimmed)) {
enqueueErrorSnackBar({ message: t`Please enter a valid URL` });
return;
}
if (formRedirectUris.includes(trimmed)) {
enqueueErrorSnackBar({ message: t`This redirect URI is already added` });
return;
}
setFormRedirectUris([...formRedirectUris, trimmed]);
setNewRedirectUri('');
markDirty();
};
const handleRemoveRedirectUri = (index: number) => {
setFormRedirectUris(
formRedirectUris.filter((_, uriIndex) => uriIndex !== index),
);
markDirty();
};
const displayedSecret = rotatedSecret ?? applicationRegistrationClientSecret;
const confirmationValue = t`yes`;
const credentialItems = [
@@ -205,7 +126,7 @@ export const SettingsApplicationRegistrationOAuthTab = ({
<>
<Section>
<H2Title
title={t`OAuth Credentials`}
title={t`OAuth`}
description={t`Credentials and scopes for OAuth authorization flows`}
/>
<SettingsTableCard
@@ -238,48 +159,14 @@ export const SettingsApplicationRegistrationOAuthTab = ({
title={t`Redirect URIs`}
description={t`Allowed redirect URIs for OAuth flows`}
/>
{formRedirectUris.map((uri, index) => (
<StyledRedirectUriRow key={`${uri}-${index}`}>
<StyledRedirectUriValue>{uri}</StyledRedirectUriValue>
<Button
Icon={IconTrash}
variant="tertiary"
size="medium"
onClick={() => handleRemoveRedirectUri(index)}
/>
</StyledRedirectUriRow>
))}
<StyledInputContainer>
<SettingsTextInput
instanceId="application-registration-new-redirect-uri"
value={newRedirectUri}
onChange={setNewRedirectUri}
placeholder={t`https://example.com/callback`}
fullWidth
/>
<Button
title={t`Add`}
variant="secondary"
size="medium"
onClick={handleAddRedirectUri}
/>
</StyledInputContainer>
{hasChanges && (
<StyledSaveContainer>
<Button
title={t`Save`}
variant="primary"
accent="blue"
onClick={handleSave}
disabled={isLoading}
/>
<Button
title={t`Cancel`}
variant="secondary"
onClick={handleCancel}
/>
</StyledSaveContainer>
)}
<SettingsApplicationRegistrationRedirectURIsInput
redirectUris={formRedirectUris}
updateRedirectUris={handleSave}
/>
<SettingsApplicationRegistrationRedirectURIsTable
redirectUris={formRedirectUris}
updateRedirectUris={handleSave}
/>
</Section>
<ConfirmationModal
@@ -1,6 +1,5 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { SettingsEmptyPlaceholder } from '@/settings/components/SettingsEmptyPlaceholder';
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import {
StyledActionTableCell,
StyledNameTableCell,
@@ -17,15 +16,13 @@ import { useLingui } from '@lingui/react/macro';
import { useContext, useMemo, useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Tag } from 'twenty-ui/components';
import {
Avatar,
CommandBlock,
H2Title,
IconApps,
IconChevronRight,
IconCopy,
IconFileInfo,
IconArrowUpRight,
OverflowingTextWithTooltip,
InlineBanner,
} from 'twenty-ui/display';
@@ -35,17 +32,20 @@ import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import {
type ApplicationRegistrationFragmentFragment,
ApplicationRegistrationSourceType,
FeatureFlagKey,
FindManyApplicationRegistrationsDocument,
} from '~/generated-metadata/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { useMarketplaceApps } from '~/modules/marketplace/hooks/useMarketplaceApps';
import {
APPLICATION_TABLE_ROW_GRID_TEMPLATE_COLUMNS,
SettingsApplicationTableRow,
} from '~/pages/settings/applications/components/SettingsApplicationTableRow';
const StyledButtonContainer = styled.div`
display: flex;
justify-content: flex-end;
margin: ${themeCssVariables.spacing[2]} 0;
margin-top: ${themeCssVariables.spacing[2]};
`;
const StyledSearchInputContainer = styled.div`
@@ -59,28 +59,6 @@ const StyledTableRowsContainer = styled.div`
const NPM_PACKAGES_GRID_COLUMNS = '200px 1fr 36px';
const SOURCE_TYPE_BADGE_CONFIG: Record<
ApplicationRegistrationSourceType,
{ label: string; color: 'gray' | 'blue' | 'green' }
> = {
[ApplicationRegistrationSourceType.LOCAL]: {
label: 'Dev',
color: 'gray',
},
[ApplicationRegistrationSourceType.NPM]: {
label: 'Npm',
color: 'blue',
},
[ApplicationRegistrationSourceType.TARBALL]: {
label: 'Internal',
color: 'green',
},
[ApplicationRegistrationSourceType.OAUTH_ONLY]: {
label: 'OAuth',
color: 'blue',
},
};
export const SettingsApplicationsDeveloperTab = () => {
const { t } = useLingui();
const { theme } = useContext(ThemeContext);
@@ -90,28 +68,31 @@ export const SettingsApplicationsDeveloperTab = () => {
const { copyToClipboard } = useCopyToClipboard();
const { data, loading } = useQuery(FindManyApplicationRegistrationsDocument);
const { data } = useQuery(FindManyApplicationRegistrationsDocument);
const isMarketplaceSettingTabVisible = useIsFeatureEnabled(
FeatureFlagKey.IS_MARKETPLACE_SETTING_TAB_VISIBLE,
);
const [npmSearchTerm, setNpmSearchTerm] = useState('');
const [marketplaceAppSearchTerm, setMarketplaceAppSearchTerm] = useState('');
const [myAppsSearchTerm, setMyAppsSearchTerm] = useState('');
const { data: marketplaceApps } = useMarketplaceApps();
const filteredMarketplaceApps = useMemo(() => {
if (!npmSearchTerm) {
if (!marketplaceAppSearchTerm) {
return marketplaceApps;
}
const lowerSearch = npmSearchTerm.toLowerCase();
const lowerSearch = marketplaceAppSearchTerm.toLowerCase();
return marketplaceApps.filter(
(application) =>
application.name.toLowerCase().includes(lowerSearch) ||
application.description.toLowerCase().includes(lowerSearch),
);
}, [marketplaceApps, npmSearchTerm]);
}, [marketplaceApps, marketplaceAppSearchTerm]);
const registrations: ApplicationRegistrationFragmentFragment[] =
data?.findManyApplicationRegistrations ?? [];
@@ -143,36 +124,6 @@ export const SettingsApplicationsDeveloperTab = () => {
applicationRegistrationId: registration.id,
});
const syncCommands = [
// oxlint-disable-next-line lingui/no-unlocalized-strings
'yarn twenty dev',
];
const syncCopyButton = (
<Button
onClick={() => {
copyToClipboard(
syncCommands.join('\n'),
t`Command copied to clipboard`,
);
}}
ariaLabel={t`Copy command`}
Icon={IconCopy}
/>
);
const RowRightWithBadge = ({
item,
}: {
item: ApplicationRegistrationFragmentFragment;
}) => {
const badgeConfig = SOURCE_TYPE_BADGE_CONFIG[item.sourceType];
return (
<Tag text={badgeConfig.label} color={badgeConfig.color} preventShrink />
);
};
return (
<>
<Section>
@@ -183,7 +134,9 @@ export const SettingsApplicationsDeveloperTab = () => {
<CommandBlock commands={createCommands} button={createCopyButton} />
<StyledButtonContainer>
<Button
Icon={IconFileInfo}
Icon={IconArrowUpRight}
variant={'secondary'}
size={'small'}
title={t`Read documentation`}
onClick={() =>
window.open(
@@ -198,26 +151,48 @@ export const SettingsApplicationsDeveloperTab = () => {
</StyledButtonContainer>
</Section>
<Section>
<H2Title
title={t`Your apps`}
description={t`All applications registered on this workspace`}
/>
{registrations.length > 0 ? (
<SettingsListCard
items={registrations}
getItemLabel={(registration) => registration.name}
isLoading={loading}
RowIcon={IconApps}
to={getRegistrationLink}
RowRightComponent={RowRightWithBadge}
{registrations.length > 0 && (
<Section>
<H2Title
title={t`My apps`}
description={t`Apps you're the developer of`}
/>
) : (
!loading && (
<CommandBlock commands={syncCommands} button={syncCopyButton} />
)
)}
</Section>
<StyledSearchInputContainer>
<SearchInput
placeholder={t`Search an application`}
value={myAppsSearchTerm}
onChange={setMyAppsSearchTerm}
/>
</StyledSearchInputContainer>
<Table>
<TableRow
gridTemplateColumns={APPLICATION_TABLE_ROW_GRID_TEMPLATE_COLUMNS}
>
<TableHeader> {t`Name`}</TableHeader>
<TableHeader>{''}</TableHeader>
<TableHeader>{''}</TableHeader>
<TableHeader />
</TableRow>
<StyledTableRowsContainer>
{registrations.map((registration) => {
return (
<SettingsApplicationTableRow
key={registration.id}
application={registration}
action={
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
link={getRegistrationLink(registration)}
/>
);
})}
</StyledTableRowsContainer>
</Table>
</Section>
)}
{!isMarketplaceSettingTabVisible && (
<Section>
@@ -239,8 +214,8 @@ export const SettingsApplicationsDeveloperTab = () => {
<StyledSearchInputContainer>
<SearchInput
placeholder={t`Search an application`}
value={npmSearchTerm}
onChange={setNpmSearchTerm}
value={marketplaceAppSearchTerm}
onChange={setMarketplaceAppSearchTerm}
/>
</StyledSearchInputContainer>
{filteredMarketplaceApps.length === 0 ? (
@@ -21,8 +21,8 @@ export const SettingsAvailableApplicationDetailContentTab = ({
}) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const objects = content?.objects ?? [];
const fields = content?.fields ?? [];
const objects = useMemo(() => content?.objects ?? [], [content?.objects]);
const fields = useMemo(() => content?.fields ?? [], [content?.fields]);
const logicFunctions = content?.logicFunctions ?? [];
const frontComponents = content?.frontComponents ?? [];
@@ -29,11 +29,7 @@ export const WithStandardSelected: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByRole(
'heading',
{ name: 'New Object', level: 3 },
{ timeout: 5000 },
);
await canvas.findByText('New Object');
const listingInput = await canvas.findByPlaceholderText('Listing');
const pluralInput = await canvas.findByPlaceholderText('Listings');
@@ -0,0 +1,23 @@
import { IconLock } from 'twenty-ui/display';
import { t } from '@lingui/core/macro';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledPillContainer = styled.span`
align-items: center;
background: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: 40px;
color: ${themeCssVariables.font.color.tertiary};
display: inline-flex;
font-weight: ${themeCssVariables.font.weight.medium};
gap: ${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]};
`;
export const OrganizationAdornment = () => (
<StyledPillContainer>
<IconLock size={12} />
{t`Organization`}
</StyledPillContainer>
);
@@ -295,7 +295,9 @@ export const SettingsSecurity = () => {
</Card>
) : (
<SettingsEnterpriseFeatureGateCard
title={t`Enterprise feature`}
description={t`Upgrade to Enterprise to access audit logs.`}
buttonTitle={t`Activate`}
/>
)}
</Section>
@@ -90,6 +90,10 @@ export class ApplicationRegistrationVariableService {
updateData.encryptedValue = this.encryptionService.encrypt(update.value);
}
if (isDefined(update.resetValue) && update.resetValue) {
updateData.encryptedValue = '';
}
if (isDefined(update.description)) {
updateData.description = update.description;
}
@@ -18,6 +18,10 @@ export class UpdateApplicationRegistrationVariablePayload {
@IsOptional()
value?: string;
@Field({ nullable: true })
@IsOptional()
resetValue?: boolean;
@Field({ nullable: true })
@IsString()
@MaxLength(2000)
@@ -60,7 +60,6 @@ export class ApplicationRegistrationResolver {
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly applicationTarballService: ApplicationTarballService,
private readonly fileUrlService: FileUrlService,
private readonly domainServerConfigService: DomainServerConfigService,
) {}
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@@ -24,6 +24,7 @@ import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/appli
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
const BCRYPT_SALT_ROUNDS = 10;
@Injectable()
@@ -35,6 +36,7 @@ export class ApplicationRegistrationService {
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
) {}
async findMany(
@@ -288,26 +290,41 @@ export class ApplicationRegistrationService {
isListed: params.isListed,
isFeatured: params.isFeatured,
});
} else {
const registration = this.applicationRegistrationRepository.create({
universalIdentifier: params.universalIdentifier,
name: params.name,
sourceType: params.sourceType,
sourcePackage: params.sourcePackage,
latestAvailableVersion: params.latestAvailableVersion,
isListed: params.isListed,
isFeatured: params.isFeatured,
manifest: params.manifest,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
ownerWorkspaceId: params.ownerWorkspaceId,
});
await this.applicationRegistrationRepository.save(registration);
}
if (!isDefined(params.manifest?.application?.serverVariables)) {
return;
}
const registration = this.applicationRegistrationRepository.create({
universalIdentifier: params.universalIdentifier,
name: params.name,
sourceType: params.sourceType,
sourcePackage: params.sourcePackage,
latestAvailableVersion: params.latestAvailableVersion,
isListed: params.isListed,
isFeatured: params.isFeatured,
manifest: params.manifest,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
ownerWorkspaceId: params.ownerWorkspaceId,
});
const registration = await this.findOneByUniversalIdentifier(
params.universalIdentifier,
);
await this.applicationRegistrationRepository.save(registration);
if (!isDefined(registration)) {
return;
}
await this.applicationRegistrationVariableService.syncVariableSchemas(
registration.id,
params.manifest.application.serverVariables,
);
}
async createCliRegistrationIfNotExists(): Promise<ApplicationRegistrationEntity | null> {
@@ -337,7 +354,10 @@ export class ApplicationRegistrationService {
async findManyListed(): Promise<ApplicationRegistrationEntity[]> {
return this.applicationRegistrationRepository.find({
where: { isListed: true },
where: {
isListed: true,
sourceType: ApplicationRegistrationSourceType.NPM,
},
});
}
@@ -22,6 +22,8 @@ import { readJsonFile } from 'src/engine/core-modules/application/application-pa
import { resolvePackageContentDir } from 'src/engine/core-modules/application/application-package/utils/tarball-utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import type { ApplicationManifest } from 'twenty-shared/application';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
export const MAX_TARBALL_UPLOAD_SIZE_BYTES = 50 * 1024 * 1024;
@@ -34,6 +36,7 @@ export class ApplicationTarballService {
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
private readonly fileStorageService: FileStorageService,
private readonly applicationService: ApplicationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
) {}
async uploadTarball(params: {
@@ -58,10 +61,7 @@ export class ApplicationTarballService {
const contentDir = await resolvePackageContentDir(extractDir);
const manifest = await readJsonFile<{
application?: {
universalIdentifier?: string;
displayName?: string;
};
application?: ApplicationManifest;
}>(contentDir, 'manifest.json');
const packageJson = await readJsonFile<{
@@ -181,6 +181,13 @@ export class ApplicationTarballService {
ownerWorkspaceId: params.ownerWorkspaceId,
});
if (manifest.application?.serverVariables) {
await this.applicationRegistrationVariableService.syncVariableSchemas(
appRegistration.id,
manifest.application.serverVariables,
);
}
this.logger.log(
`Tarball uploaded for app ${universalIdentifier} (registration ${appRegistration.id})`,
);
@@ -168,23 +168,6 @@ export class LocalDriver implements LogicFunctionDriver {
async delete() {}
async build({
flatApplication,
applicationUniversalIdentifier,
}: {
flatApplication: FlatApplication;
applicationUniversalIdentifier: string;
}) {
await this.createLayerIfNotExist({
flatApplication,
applicationUniversalIdentifier,
});
await this.ensureSdkLayer({
flatApplication,
applicationUniversalIdentifier,
});
}
// Symlinks everything from the deps layer except twenty-client-sdk,
// which comes from the SDK layer (workspace-specific generated client).
private async assembleNodeModules({
@@ -76,3 +76,15 @@ export class PublicWorkspaceDataDTO {
@Field(() => WorkspaceUrlsDTO)
workspaceUrls: WorkspaceUrlsDTO;
}
@ObjectType('PublicWorkspaceDataSummary')
export class PublicWorkspaceDataSummaryDTO {
@Field(() => UUIDScalarType)
id: string;
@Field(() => String, { nullable: true })
logo: WorkspaceEntity['logo'];
@Field(() => String, { nullable: true })
displayName: WorkspaceEntity['displayName'];
}
@@ -820,4 +820,10 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
this.exceptionHandlerService.captureExceptions([error as Error]);
}
}
async findOneWorkspaceById(id: string) {
return await this.workspaceRepository.findOneBy({
id,
});
}
}
@@ -39,6 +39,7 @@ import { ActivateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/a
import {
type AuthProvidersDTO,
PublicWorkspaceDataDTO,
PublicWorkspaceDataSummaryDTO,
} from 'src/engine/core-modules/workspace/dtos/public-workspace-data.dto';
import { UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input';
import { WorkspaceUrlsDTO } from 'src/engine/core-modules/workspace/dtos/workspace-urls.dto';
@@ -69,6 +70,7 @@ import { fromRoleEntityToRoleDto } from 'src/engine/metadata-modules/role/utils/
import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { getRequest } from 'src/utils/extract-request';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
const OriginHeader = createParamDecorator(
(_: unknown, ctx: ExecutionContext) => {
const request = getRequest(ctx);
@@ -418,6 +420,39 @@ export class WorkspaceResolver {
}
}
@Query(() => PublicWorkspaceDataSummaryDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async getPublicWorkspaceDataById(
@Args({
name: 'id',
type: () => UUIDScalarType,
nullable: false,
})
id: string,
): Promise<PublicWorkspaceDataSummaryDTO | undefined> {
try {
const workspace = await this.workspaceService.findOneWorkspaceById(id);
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
const logo = isDefined(workspace.logoFileId)
? this.fileUrlService.signFileByIdUrl({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
fileFolder: FileFolder.CorePicture,
})
: (workspace.logo ?? '');
return {
id: workspace.id,
logo,
displayName: workspace.displayName,
};
} catch (err) {
workspaceGraphqlApiExceptionHandler(err);
}
}
@Mutation(() => DomainValidRecords, { nullable: true })
@UseGuards(
WorkspaceAuthGuard,
@@ -45,6 +45,7 @@ export enum SettingsPath {
ApplicationLogicFunctionDetail = 'applications/:applicationId/logicFunctions/:logicFunctionId',
AvailableApplicationDetail = 'applications/available/:availableApplicationId',
ApplicationRegistrationDetail = 'applications/registrations/:applicationRegistrationId',
ApplicationRegistrationConfigVariableDetails = 'applications/registrations/:applicationRegistrationId/config-variables/:variableKey',
LogicFunctions = 'functions',
NewLogicFunction = 'functions/new',
LogicFunctionDetail = 'functions/:logicFunctionId',
@@ -7,7 +7,7 @@ import { type ThemeColor } from '@ui/theme';
import { ThemeContext, themeCssVariables } from '@ui/theme-constants';
import { useContext } from 'react';
const StyledTag = styled.h3<{
const StyledTag = styled.span<{
color: TagColor;
weight: TagWeight;
variant: TagVariant;
@@ -31,7 +31,7 @@ export const Default: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const tag = canvas.getByRole('heading', { level: 3 });
const tag = canvas.getByText('Urgent');
await userEvent.click(tag);
await expect(args.onClick).toHaveBeenCalled();
@@ -7,6 +7,7 @@ const StyledContainer = styled.div`
border: 1px solid ${themeCssVariables.border.color.medium};
background: ${themeCssVariables.background.secondary};
display: flex;
align-items: center;
justify-content: space-between;
padding: ${themeCssVariables.spacing[3]};
gap: ${themeCssVariables.spacing[3]};
@@ -14,6 +14,7 @@ export {
IconArchive,
IconArchiveOff,
IconArrowBackUp,
IconArrowBarToDown,
IconArrowDown,
IconArrowLeft,
IconArrowMerge,
@@ -44,6 +45,7 @@ export {
IconBracketsContain,
IconBrain,
IconBrandDaysCounter,
IconBrandDocker,
IconBrandGithub,
IconBrandGoogle,
IconBrandGraphql,
@@ -199,6 +201,7 @@ export {
IconFrame,
IconFunction,
IconGauge,
IconGitBranch,
IconGitBranchDeleted,
IconGitCommit,
IconGizmo,
+3
View File
@@ -93,6 +93,7 @@ export {
IconArchive,
IconArchiveOff,
IconArrowBackUp,
IconArrowBarToDown,
IconArrowDown,
IconArrowLeft,
IconArrowMerge,
@@ -123,6 +124,7 @@ export {
IconBracketsContain,
IconBrain,
IconBrandDaysCounter,
IconBrandDocker,
IconBrandGithub,
IconBrandGoogle,
IconBrandGraphql,
@@ -278,6 +280,7 @@ export {
IconFrame,
IconFunction,
IconGauge,
IconGitBranch,
IconGitBranchDeleted,
IconGitCommit,
IconGizmo,