Compare commits

...

3 Commits

Author SHA1 Message Date
martmull 6a32032b78 Use universal identifiers in all setting paths 2026-04-16 19:53:52 +02:00
martmull ea5091f8c0 Add tabs in admin apps details 2026-04-16 15:45:47 +02:00
martmull 5f5f654602 Fix config settings 2026-04-16 15:00:39 +02:00
42 changed files with 1169 additions and 1282 deletions
@@ -2524,6 +2524,7 @@ type File {
type MarketplaceApp {
id: String!
universalIdentifier: String!
name: String!
description: String!
icon: String!
@@ -2235,6 +2235,7 @@ export interface File {
export interface MarketplaceApp {
id: Scalars['String']
universalIdentifier: Scalars['String']
name: Scalars['String']
description: Scalars['String']
icon: Scalars['String']
@@ -5564,6 +5565,7 @@ export interface FileGenqlSelection{
export interface MarketplaceAppGenqlSelection{
id?: boolean | number
universalIdentifier?: boolean | number
name?: boolean | number
description?: boolean | number
icon?: boolean | number
@@ -5036,6 +5036,9 @@ export default {
"id": [
1
],
"universalIdentifier": [
1
],
"name": [
1
],
File diff suppressed because one or more lines are too long
@@ -172,14 +172,6 @@ const SettingsApplications = lazy(() =>
),
);
const SettingsApplicationDetails = lazy(() =>
import('~/pages/settings/applications/SettingsApplicationDetails').then(
(module) => ({
default: module.SettingsApplicationDetails,
}),
),
);
const SettingsAdminApplicationRegistrationDetail = lazy(() =>
import(
'~/pages/settings/admin-panel/SettingsAdminApplicationRegistrationDetail'
@@ -188,12 +180,12 @@ const SettingsAdminApplicationRegistrationDetail = lazy(() =>
})),
);
const SettingsAvailableApplicationDetails = lazy(() =>
import(
'~/pages/settings/applications/SettingsAvailableApplicationDetails'
).then((module) => ({
default: module.SettingsAvailableApplicationDetails,
})),
const SettingsApplicationPage = lazy(() =>
import('~/pages/settings/applications/SettingsApplicationPage').then(
(module) => ({
default: module.SettingsApplicationPage,
}),
),
);
const SettingsApplicationRegistrationDetails = lazy(() =>
@@ -738,11 +730,7 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
/>
<Route
path={SettingsPath.ApplicationDetail}
element={<SettingsApplicationDetails />}
/>
<Route
path={SettingsPath.AvailableApplicationDetail}
element={<SettingsAvailableApplicationDetails />}
element={<SettingsApplicationPage />}
/>
<Route
path={SettingsPath.ApplicationRegistrationDetail}
@@ -0,0 +1,15 @@
import { gql } from '@apollo/client';
import { APPLICATION_REGISTRATION_FRAGMENT } from '@/settings/application-registrations/graphql/fragments/applicationRegistrationFragment';
export const FIND_APPLICATION_REGISTRATION_BY_UNIVERSAL_IDENTIFIER = gql`
query findApplicationRegistrationByUniversalIdentifier(
$universalIdentifier: String!
) {
findApplicationRegistrationByUniversalIdentifier(
universalIdentifier: $universalIdentifier
) {
...ApplicationRegistrationFragment
}
${APPLICATION_REGISTRATION_FRAGMENT}
}
`;
@@ -3,6 +3,7 @@ import gql from 'graphql-tag';
export const MARKETPLACE_APP_FRAGMENT = gql`
fragment MarketplaceAppFields on MarketplaceApp {
id
universalIdentifier
name
description
icon
@@ -102,7 +102,10 @@ export const SettingsAdminApps = () => {
key={registration.id}
to={getSettingsPath(
SettingsPath.AdminPanelApplicationRegistrationDetail,
{ applicationRegistrationId: registration.id },
{
applicationUniversalIdentifier:
registration.universalIdentifier,
},
)}
gridAutoColumns={TABLE_GRID}
mobileGridAutoColumns={TABLE_GRID_MOBILE}
@@ -1,65 +0,0 @@
import { useLingui } from '@lingui/react/macro';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
import {
IconDeviceFloppy,
IconPencil,
IconRefreshAlert,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import {
ConfigSource,
type ConfigVariable,
} from '~/generated-metadata/graphql';
type ConfigVariableActionButtonsProps = {
variable: ConfigVariable;
isValueValid: boolean;
isSubmitting: boolean;
onSave: () => void;
onReset: () => void;
};
export const ConfigVariableActionButtons = ({
variable,
isValueValid,
isSubmitting,
onSave,
onReset,
}: ConfigVariableActionButtonsProps) => {
const { t } = useLingui();
const isConfigVariablesInDbEnabled = useAtomStateValue(
isConfigVariablesInDbEnabledState,
);
const isFromDatabase = variable.source === ConfigSource.DATABASE;
return (
<>
{isConfigVariablesInDbEnabled &&
variable.source === ConfigSource.DATABASE && (
<Button
title={t`Reset to Default`}
variant="secondary"
size="small"
accent="danger"
disabled={isSubmitting}
onClick={onReset}
Icon={IconRefreshAlert}
/>
)}
{isConfigVariablesInDbEnabled && !variable.isEnvOnly && (
<Button
title={isFromDatabase ? t`Save` : t`Edit`}
variant="primary"
size="small"
accent="blue"
disabled={isSubmitting || !isValueValid}
onClick={onSave}
type="submit"
Icon={isFromDatabase ? IconDeviceFloppy : IconPencil}
/>
)}
</>
);
};
@@ -1,82 +0,0 @@
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { styled } from '@linaria/react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconChevronRight } from 'twenty-ui/display';
import { useContext } from 'react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { type ConfigVariable } from '~/generated-metadata/graphql';
type SettingsAdminConfigVariablesRowProps = {
variable: ConfigVariable;
};
const StyledTableRowContainer = styled.div`
> * {
&:hover {
background-color: ${themeCssVariables.background.transparent.light};
}
}
`;
const StyledEllipsisLabel = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const SettingsAdminConfigVariablesRow = ({
variable,
}: SettingsAdminConfigVariablesRowProps) => {
const { theme } = useContext(ThemeContext);
const displayValue =
variable.value === ''
? 'null'
: variable.isSensitive
? '••••••'
: typeof variable.value === 'boolean'
? variable.value
? 'true'
: 'false'
: typeof variable.value === 'object' && variable.value !== null
? JSON.stringify(variable.value)
: variable.value;
return (
<StyledTableRowContainer>
<TableRow
gridAutoColumns="5fr 3fr 1fr"
to={getSettingsPath(SettingsPath.AdminPanelConfigVariableDetails, {
variableName: variable.name,
})}
>
<TableCell
color={theme.font.color.primary}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
clickable
>
<StyledEllipsisLabel>{variable.name}</StyledEllipsisLabel>
</TableCell>
<TableCell
align="right"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
clickable
>
<StyledEllipsisLabel>{displayValue}</StyledEllipsisLabel>
</TableCell>
<TableCell align="right">
<IconChevronRight
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
</TableCell>
</TableRow>
</StyledTableRowContainer>
);
};
@@ -1,16 +1,7 @@
import { t } from '@lingui/core/macro';
import { SettingsAdminConfigVariablesRow } from '@/settings/admin-panel/config-variables/components/SettingsAdminConfigVariablesRow';
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 { themeCssVariables } from 'twenty-ui/theme-constants';
import { type ConfigVariable } from '~/generated-metadata/graphql';
const StyledTableBodyContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
`;
import { ConfigVariableTable } from '@/settings/config-variables/components/ConfigVariableTable';
import { getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
type SettingsAdminConfigVariablesTableProps = {
variables: ConfigVariable[];
@@ -19,23 +10,25 @@ type SettingsAdminConfigVariablesTableProps = {
export const SettingsAdminConfigVariablesTable = ({
variables,
}: SettingsAdminConfigVariablesTableProps) => {
return (
<Table>
<TableRow gridAutoColumns="5fr 3fr 1fr">
<TableHeader>{t`Name`}</TableHeader>
<TableHeader align="right">{t`Value`}</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
<StyledTableBodyContainer>
<TableBody>
{variables.map((variable) => (
<SettingsAdminConfigVariablesRow
key={variable.name}
variable={variable}
/>
))}
</TableBody>
</StyledTableBodyContainer>
</Table>
);
const configVariables = variables.map((variable) => ({
name: variable.name,
description: variable.description,
value:
variable.value === ''
? 'null'
: variable.isSensitive
? '••••••'
: typeof variable.value === 'boolean'
? variable.value
? 'true'
: 'false'
: typeof variable.value === 'object' && variable.value !== null
? JSON.stringify(variable.value)
: variable.value,
to: getSettingsPath(SettingsPath.AdminPanelConfigVariableDetails, {
variableName: variable.name,
}),
}));
return <ConfigVariableTable configVariables={configVariables} />;
};
@@ -1,8 +1,5 @@
import { useLingui } from '@lingui/react/macro';
import { useClientConfig } from '@/client-config/hooks/useClientConfig';
import { GET_DATABASE_CONFIG_VARIABLE } from '@/settings/admin-panel/config-variables/graphql/queries/getDatabaseConfigVariable';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { type ConfigVariableValue } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
@@ -13,8 +10,6 @@ import {
} from '~/generated-metadata/graphql';
export const useConfigVariableActions = (variableName: string) => {
const { t } = useLingui();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { refetch: refetchClientConfig } = useClientConfig();
const [updateDatabaseConfigVariable] = useMutation(
@@ -31,65 +26,20 @@ export const useConfigVariableActions = (variableName: string) => {
value: ConfigVariableValue,
isFromDatabase: boolean,
) => {
try {
if (
value === null ||
(typeof value === 'string' && value === '') ||
(Array.isArray(value) && value.length === 0)
) {
await handleDeleteVariable();
return;
}
if (isFromDatabase) {
await updateDatabaseConfigVariable({
variables: {
key: variableName,
value,
},
refetchQueries: [
{
query: GET_DATABASE_CONFIG_VARIABLE,
variables: { key: variableName },
},
],
});
} else {
await createDatabaseConfigVariable({
variables: {
key: variableName,
value,
},
refetchQueries: [
{
query: GET_DATABASE_CONFIG_VARIABLE,
variables: { key: variableName },
},
],
});
}
await refetchClientConfig();
enqueueSuccessSnackBar({
message: t`Variable updated successfully.`,
});
} catch {
enqueueErrorSnackBar({
message: t`Failed to update variable`,
});
}
};
const handleDeleteVariable = async (e?: React.MouseEvent<HTMLElement>) => {
if (isDefined(e)) {
e.preventDefault();
if (
value === null ||
(typeof value === 'string' && value === '') ||
(Array.isArray(value) && value.length === 0)
) {
await handleDeleteVariable();
return;
}
try {
await deleteDatabaseConfigVariable({
if (isFromDatabase) {
await updateDatabaseConfigVariable({
variables: {
key: variableName,
value,
},
refetchQueries: [
{
@@ -98,17 +48,42 @@ export const useConfigVariableActions = (variableName: string) => {
},
],
});
await refetchClientConfig();
enqueueSuccessSnackBar({
message: t`Variable deleted successfully.`,
});
} catch {
enqueueErrorSnackBar({
message: t`Failed to remove override`,
} else {
await createDatabaseConfigVariable({
variables: {
key: variableName,
value,
},
refetchQueries: [
{
query: GET_DATABASE_CONFIG_VARIABLE,
variables: { key: variableName },
},
],
});
}
await refetchClientConfig();
};
const handleDeleteVariable = async (e?: React.MouseEvent<HTMLElement>) => {
if (isDefined(e)) {
e.preventDefault();
}
await deleteDatabaseConfigVariable({
variables: {
key: variableName,
},
refetchQueries: [
{
query: GET_DATABASE_CONFIG_VARIABLE,
variables: { key: variableName },
},
],
});
await refetchClientConfig();
};
return {
@@ -1,64 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { type ConfigVariableValue } from 'twenty-shared/types';
import { z } from 'zod';
import { type ConfigVariable } from '~/generated-metadata/graphql';
type FormValues = {
value: ConfigVariableValue;
};
const hasMeaningfulValue = (value: ConfigVariableValue): boolean => {
if (value === null || value === undefined) {
return false;
}
if (typeof value === 'string') {
return value.trim() !== '';
}
if (Array.isArray(value)) {
return value.length > 0;
}
return true;
};
export const useConfigVariableForm = (variable?: ConfigVariable) => {
const validationSchema = z.object({
value: z.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.string()),
z.record(z.string(), z.unknown()),
z.null(),
]),
});
const {
control,
handleSubmit,
reset,
formState: { isSubmitting, isDirty },
watch,
} = useForm<FormValues>({
resolver: zodResolver(validationSchema),
values: { value: variable?.value ?? null },
});
const currentValue = watch('value');
const isValueValid =
variable !== undefined &&
!variable.isEnvOnly &&
isDirty &&
hasMeaningfulValue(currentValue);
return {
control,
handleSubmit,
reset,
isSubmitting,
currentValue,
hasValueChanged: isDirty,
isValueValid,
};
};
@@ -0,0 +1,171 @@
import { styled } from '@linaria/react';
import { H3Title, IconCheck, IconPencil, IconX } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useLingui } from '@lingui/react/macro';
import { Section } from 'twenty-ui/layout';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ConfigVariableHelpText } from '@/settings/admin-panel/config-variables/components/ConfigVariableHelpText';
import { type Dispatch, type SetStateAction, useState } from 'react';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { isDefined } from 'twenty-shared/utils';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
const RESET_VARIABLE_MODAL_ID =
'reset-application-registration-config-variable-modal';
const StyledRow = styled.div`
align-items: flex-end;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledButtonContainer = styled.div`
display: flex;
& > :not(:first-of-type) > button {
border-left: none;
}
`;
type ConfigVariableEditProps = {
title: string;
description?: string;
input: React.ReactNode;
isEditing: boolean;
setIsEditing: Dispatch<SetStateAction<boolean>>;
isSaveDisabled?: boolean;
canOpenCancelModal?: boolean;
onSave?: () => Promise<void>;
onCancel: () => void;
onEdit?: () => void;
onConfirmReset?: () => Promise<void>;
editDisabled?: boolean;
helpContent?: React.ReactNode;
};
export const ConfigVariableEdit = ({
title,
description,
input,
isEditing,
setIsEditing,
canOpenCancelModal,
isSaveDisabled = false,
onSave,
onCancel,
onEdit,
onConfirmReset,
editDisabled = false,
helpContent,
}: ConfigVariableEditProps) => {
const { t } = useLingui();
const { openModal } = useModal();
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSave = async () => {
try {
setIsSubmitting(true);
await onSave?.();
enqueueSuccessSnackBar({
message: t`Variable ${title} updated`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error updating variable`,
});
} finally {
setIsSubmitting(false);
setIsEditing(false);
}
};
const handleConfirmReset = async () => {
try {
setIsSubmitting(true);
await onConfirmReset?.();
enqueueSuccessSnackBar({
message: t`Variable ${title} reset`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error resetting variable`,
});
} finally {
setIsSubmitting(false);
setIsEditing(false);
}
};
const handleCancel = () => {
if (canOpenCancelModal) {
openModal(RESET_VARIABLE_MODAL_ID);
return;
}
onCancel?.();
setIsEditing(false);
};
const handleEdit = () => {
onEdit?.();
setIsEditing(true);
};
return (
<SettingsPageContainer>
<Section>
<H3Title title={title} description={description} />
</Section>
<Section>
<StyledRow>
{input}
{!isEditing ? (
<Button
Icon={IconPencil}
variant="primary"
onClick={handleEdit}
type="button"
disabled={editDisabled}
/>
) : (
<StyledButtonContainer>
<Button
Icon={IconCheck}
variant="secondary"
position="left"
type={'button'}
onClick={handleSave}
disabled={isSaveDisabled || isSubmitting}
/>
<Button
Icon={IconX}
variant="secondary"
position="right"
onClick={handleCancel}
type="button"
disabled={isSubmitting}
/>
</StyledButtonContainer>
)}
<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"
/>
</StyledRow>
{helpContent}
</Section>
</SettingsPageContainer>
);
};
@@ -0,0 +1,95 @@
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { Table } from '@/ui/layout/table/components/Table';
import { styled } from '@linaria/react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { t } from '@lingui/core/macro';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import {
IconChevronRight,
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { useContext } from 'react';
const StyledTableBodyContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
`;
const GRID_AUTO_COLUMNS = '5fr 3fr 3fr 1fr';
type ConfigVariable = {
name: string;
description?: string;
value?: string | React.ReactNode;
to: string;
};
type ConfigVariableTableProps = { configVariables: ConfigVariable[] };
export const ConfigVariableTable = ({
configVariables,
}: ConfigVariableTableProps) => {
const { theme } = useContext(ThemeContext);
return (
<Table>
<TableRow gridAutoColumns={GRID_AUTO_COLUMNS}>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader align="right">{t`Description`}</TableHeader>
<TableHeader align="right">{t`Value`}</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
<StyledTableBodyContainer>
<TableBody>
{configVariables.map((variable, index) => (
<TableRow
key={index}
gridAutoColumns={GRID_AUTO_COLUMNS}
to={variable.to}
>
<TableCell
color={theme.font.color.primary}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
clickable
>
<OverflowingTextWithTooltip text={variable.name} />
</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
>
{typeof variable.value === 'string' ? (
<OverflowingTextWithTooltip text={variable.value} />
) : (
variable.value
)}
</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>
);
};
@@ -1,36 +0,0 @@
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { SettingsPath } from 'twenty-shared/types';
import { LinkChip } from 'twenty-ui/components';
import { getSettingsPath } from 'twenty-shared/utils';
import { useParams } from 'react-router-dom';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
export const SettingsLogicFunctionTabEnvironmentVariablesSection = () => {
const { applicationId = '' } = useParams<{ applicationId: string }>();
return (
<Section>
<H2Title
title={t`Environment Variables`}
description={t`Accessible in your function via process.env.KEY`}
/>
<Trans>
Environment variables are defined at application level for all
functions. Please check{' '}
<LinkChip
label={t`application detail page`}
to={getSettingsPath(
SettingsPath.ApplicationDetail,
{
applicationId,
},
undefined,
'settings',
)}
/>
.
</Trans>
</Section>
);
};
@@ -1,5 +1,4 @@
import { SettingsLogicFunctionNewForm } from '@/settings/logic-functions/components/SettingsLogicFunctionNewForm';
import { SettingsLogicFunctionTabEnvironmentVariablesSection } from '@/settings/logic-functions/components/SettingsLogicFunctionTabEnvironmentVariablesSection';
import { type LogicFunctionFormValues } from '@/logic-functions/hooks/useLogicFunctionUpdateFormState';
export const SettingsLogicFunctionSettingsTab = ({
@@ -12,12 +11,6 @@ export const SettingsLogicFunctionSettingsTab = ({
) => (value: LogicFunctionFormValues[TKey]) => void;
}) => {
return (
<>
<SettingsLogicFunctionNewForm
formValues={formValues}
onChange={onChange}
/>
<SettingsLogicFunctionTabEnvironmentVariablesSection />
</>
<SettingsLogicFunctionNewForm formValues={formValues} onChange={onChange} />
);
};
@@ -0,0 +1,248 @@
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 { 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 {
H1Title,
H1TitleFontColor,
H2Title,
IconShare,
IconTrash,
AppTooltip,
TooltipDelay,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section, SectionAlignment, SectionFontColor } from 'twenty-ui/layout';
import {
type ApplicationRegistration,
DeleteApplicationRegistrationDocument,
FindApplicationRegistrationStatsDocument,
FindManyApplicationRegistrationsDocument,
TransferApplicationRegistrationOwnershipDocument,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
StyledAppModal,
StyledAppModalButton,
StyledAppModalSection,
StyledAppModalTitle,
} from '~/pages/settings/applications/components/SettingsAppModalLayout';
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 DELETE_REGISTRATION_BUTTON_ID = 'delete-registration-button';
const StyledDangerButtonGroup = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
export const SettingsAdminApplicationRegistrationDangerZone = ({
registration,
}: {
registration: ApplicationRegistration;
}) => {
const { t } = useLingui();
const navigate = useNavigateSettings();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { openModal, closeModal } = useModal();
const [isLoading, setIsLoading] = useState(false);
const [isTransferring, setIsTransferring] = useState(false);
const [transferSubdomain, setTransferSubdomain] = useState('');
const applicationRegistrationId = registration.id;
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,
{
refetchQueries: [FindManyApplicationRegistrationsDocument],
},
);
const [transferOwnership] = useMutation(
TransferApplicationRegistrationOwnershipDocument,
{
refetchQueries: [FindManyApplicationRegistrationsDocument],
},
);
const handleDelete = async () => {
setIsLoading(true);
try {
await deleteRegistration({
variables: { id: applicationRegistrationId },
});
navigate(SettingsPath.Applications);
} catch {
enqueueErrorSnackBar({
message: t`Error deleting app`,
});
} finally {
setIsLoading(false);
}
};
const handleTransferOwnership = async () => {
const trimmed = transferSubdomain.trim();
if (!isNonEmptyString(trimmed)) {
return;
}
setIsTransferring(true);
try {
await transferOwnership({
variables: {
applicationRegistrationId,
targetWorkspaceSubdomain: trimmed,
},
});
enqueueSuccessSnackBar({
message: t`Ownership transferred successfully`,
});
setTransferSubdomain('');
navigate(SettingsPath.Applications);
} catch {
enqueueErrorSnackBar({
message: t`Failed to transfer ownership. Check that the subdomain is correct.`,
});
} finally {
setIsTransferring(false);
}
};
const confirmationValue = t`yes`;
return (
<>
<Section>
<H2Title
title={t`Danger zone`}
description={t`Delete or transfer this app registration`}
/>
<StyledDangerButtonGroup>
<Button
id={DELETE_REGISTRATION_BUTTON_ID}
accent="danger"
variant="secondary"
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={IconShare}
onClick={() => openModal(TRANSFER_OWNERSHIP_MODAL_ID)}
/>
</StyledDangerButtonGroup>
</Section>
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalInstanceId={DELETE_REGISTRATION_MODAL_ID}
title={t`Delete app`}
subtitle={
<Trans>
Please type {`"${confirmationValue}"`} to confirm you want to delete
this app. All workspace installations linked to it will lose their
OAuth credentials.
</Trans>
}
onConfirmClick={handleDelete}
confirmButtonText={t`Delete`}
loading={isLoading}
/>
<StyledAppModal
modalId={TRANSFER_OWNERSHIP_MODAL_ID}
isClosable
onClose={() => setTransferSubdomain('')}
padding="large"
dataGloballyPreventClickOutside
>
<StyledAppModalTitle>
<H1Title
title={t`Transfer ownership`}
fontColor={H1TitleFontColor.Primary}
/>
</StyledAppModalTitle>
<StyledAppModalSection
alignment={SectionAlignment.Center}
fontColor={SectionFontColor.Primary}
>
{t`Enter the workspace subdomain to transfer this app to. You will lose access to manage it.`}
</StyledAppModalSection>
<Section>
<SettingsTextInput
instanceId="transfer-ownership-subdomain"
value={transferSubdomain}
onChange={setTransferSubdomain}
placeholder={t`e.g. my-workspace`}
fullWidth
disableHotkeys
label={t`Target workspace subdomain`}
autoFocusOnMount
/>
</Section>
<StyledAppModalButton
onClick={() => {
closeModal(TRANSFER_OWNERSHIP_MODAL_ID);
setTransferSubdomain('');
}}
variant="secondary"
title={t`Cancel`}
fullWidth
/>
<StyledAppModalButton
onClick={handleTransferOwnership}
variant="secondary"
accent="danger"
title={t`Transfer`}
disabled={
!isNonEmptyString(transferSubdomain.trim()) || isTransferring
}
fullWidth
/>
</StyledAppModal>
</>
);
};
@@ -1,50 +1,46 @@
import { useParams } from 'react-router-dom';
import { useMutation, useQuery } from '@apollo/client/react';
import {
FindAllApplicationRegistrationsDocument,
FindOneAdminApplicationRegistrationDocument,
UpdateApplicationRegistrationDocument,
} from '~/generated-metadata/graphql';
import { useQuery } from '@apollo/client/react';
import { FindOneAdminApplicationRegistrationDocument } from '~/generated-metadata/graphql';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
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';
import {
IconInfoCircle,
IconKey,
IconSettings,
IconWorld,
} from 'twenty-ui/display';
import { SettingsApplicationRegistrationConfigTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationConfigTab';
import { SettingsApplicationRegistrationOAuthTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationOAuthTab';
import { SettingsApplicationRegistrationDistributionTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationDistributionTab';
import { SettingsApplicationRegistrationGeneralTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationGeneralTab';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
const StyledToggleContainer = styled.div`
display: flex;
margin-top: ${themeCssVariables.spacing[4]};
`;
const REGISTRATION_DETAIL_TAB_LIST_ID =
'admin-application-registration-detail-tab-list';
export const SettingsAdminApplicationRegistrationDetail = () => {
const { t } = useLingui();
const { applicationRegistrationId = '' } = useParams<{
applicationRegistrationId: string;
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
REGISTRATION_DETAIL_TAB_LIST_ID,
);
const { applicationUniversalIdentifier = '' } = useParams<{
applicationUniversalIdentifier: string;
}>();
const { data, loading } = useQuery(
FindOneAdminApplicationRegistrationDocument,
{
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const [updateRegistration] = useMutation(
UpdateApplicationRegistrationDocument,
{
refetchQueries: [
FindOneAdminApplicationRegistrationDocument,
FindAllApplicationRegistrationsDocument,
],
variables: { id: applicationUniversalIdentifier },
skip: !applicationUniversalIdentifier,
},
);
@@ -54,6 +50,44 @@ export const SettingsAdminApplicationRegistrationDetail = () => {
return null;
}
const tabs = [
{ 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
registration={registration}
/>
);
case 'distribution':
return (
<SettingsApplicationRegistrationDistributionTab
registration={registration}
/>
);
case 'general':
default:
return (
<SettingsApplicationRegistrationGeneralTab
registration={registration}
displayAdminToggles
/>
);
}
};
return (
<SubMenuTopBarContainer
title={registration.name}
@@ -70,31 +104,11 @@ export const SettingsAdminApplicationRegistrationDetail = () => {
]}
>
<SettingsPageContainer>
<SettingsAdminApplicationRegistrationDetailContent
registration={registration}
<TabList
tabs={tabs}
componentInstanceId={REGISTRATION_DETAIL_TAB_LIST_ID}
/>
<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>
{renderActiveTabContent()}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
@@ -0,0 +1,53 @@
import { Card, Section } from 'twenty-ui/layout';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { IconArrowBarToDown } from 'twenty-ui/display';
import {
ApplicationRegistration,
UpdateApplicationRegistrationDocument,
} from '~/generated-metadata/graphql';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useMutation } from '@apollo/client/react';
import { useLingui } from '@lingui/react/macro';
const StyledToggleContainer = styled.div`
display: flex;
margin-top: ${themeCssVariables.spacing[4]};
`;
export const SettingsAdminApplicationRegistrationGeneralToggles = ({
registration,
}: {
registration: ApplicationRegistration;
}) => {
const { t } = useLingui();
const [updateRegistration] = useMutation(
UpdateApplicationRegistrationDocument,
);
return (
<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>
);
};
@@ -1,64 +1,43 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { Controller } from 'react-hook-form';
import { Form, useParams } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
import { ConfigVariableHelpText } from '@/settings/admin-panel/config-variables/components/ConfigVariableHelpText';
import { ConfigVariableValueInput } from '@/settings/admin-panel/config-variables/components/ConfigVariableValueInput';
import { useConfigVariableActions } from '@/settings/admin-panel/config-variables/hooks/useConfigVariableActions';
import { useConfigVariableForm } from '@/settings/admin-panel/config-variables/hooks/useConfigVariableForm';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ConfigVariableEdit } from '@/settings/config-variables/components/ConfigVariableEdit';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { SettingsPath, type ConfigVariableValue } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H3Title, IconCheck, IconPencil, IconX } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useQuery } from '@apollo/client/react';
import {
ConfigSource,
GetDatabaseConfigVariableDocument,
} from '~/generated-metadata/graphql';
const StyledFormContainer = styled.div`
> form {
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
width: 100%;
const hasMeaningfulValue = (value: ConfigVariableValue): boolean => {
if (value === null || value === undefined) {
return false;
}
`;
const StyledH3TitleContainer = styled.div`
margin-top: ${themeCssVariables.spacing[2]};
`;
const StyledRow = styled.div`
align-items: flex-end;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledButtonContainer = styled.div`
display: flex;
& > :not(:first-of-type) > button {
border-left: none;
if (typeof value === 'string') {
return value.trim() !== '';
}
`;
const RESET_VARIABLE_MODAL_ID = 'reset-variable-modal';
if (Array.isArray(value)) {
return value.length > 0;
}
return true;
};
export const SettingsAdminConfigVariableDetails = () => {
const { variableName } = useParams();
const { t } = useLingui();
const [isEditing, setIsEditing] = useState(false);
const { openModal } = useModal();
const isConfigVariablesInDbEnabled = useAtomStateValue(
isConfigVariablesInDbEnabledState,
);
@@ -76,138 +55,92 @@ export const SettingsAdminConfigVariableDetails = () => {
const { handleUpdateVariable, handleDeleteVariable } =
useConfigVariableActions(variable?.name ?? '');
const {
control,
handleSubmit,
reset,
isSubmitting,
hasValueChanged,
isValueValid,
} = useConfigVariableForm(variable);
const [value, setValue] = useState<ConfigVariableValue>(
variable?.value ?? null,
);
if (loading === true || isDefined(variable) === false) {
return <SettingsSkeletonLoader />;
}
const isEnvOnly = variable.isEnvOnly;
const isFromDatabase = variable.source === ConfigSource.DATABASE;
const onSubmit = async (formData: { value: ConfigVariableValue }) => {
await handleUpdateVariable(formData.value, isFromDatabase);
setIsEditing(false);
const hasValueChanged =
JSON.stringify(value) !== JSON.stringify(variable.value);
const isValueValid =
!isEnvOnly && hasValueChanged && hasMeaningfulValue(value);
const onSave = async () => {
await handleUpdateVariable(value, isFromDatabase);
};
const handleEditClick = () => {
const onEdit = () => {
if (variable.isSensitive) {
reset({ value: '' });
setValue('');
}
setIsEditing(true);
};
const handleXButtonClick = () => {
if (isFromDatabase && !hasValueChanged) {
openModal(RESET_VARIABLE_MODAL_ID);
return;
}
const canOpenCancelModal = isFromDatabase && !hasValueChanged;
reset({ value: variable.value });
setIsEditing(false);
const onCancel = () => {
setValue(variable.value);
};
const handleConfirmReset = () => {
handleDeleteVariable();
setIsEditing(false);
const onConfirmReset = async () => {
await handleDeleteVariable();
};
return (
<>
<SubMenuTopBarContainer
links={[
{
children: t`Other`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel - Config`,
href: getSettingsPath(
SettingsPath.AdminPanel,
undefined,
undefined,
'config-variables',
),
},
{
children: variable.name,
},
]}
>
<SettingsPageContainer>
<StyledH3TitleContainer>
<H3Title title={variable.name} description={variable.description} />
</StyledH3TitleContainer>
<StyledFormContainer>
<Form onSubmit={handleSubmit(onSubmit)}>
<StyledRow>
<Controller
control={control}
name="value"
render={({ field }) => (
<ConfigVariableValueInput
variable={variable}
value={field.value}
onChange={field.onChange}
disabled={isEnvOnly || !isEditing}
/>
)}
/>
{!isEditing ? (
<Button
Icon={IconPencil}
variant="primary"
onClick={handleEditClick}
type="button"
disabled={isEnvOnly || !isConfigVariablesInDbEnabled}
/>
) : (
<StyledButtonContainer>
<Button
Icon={IconCheck}
variant="secondary"
position="left"
type="submit"
disabled={isSubmitting || !isValueValid}
/>
<Button
Icon={IconX}
variant="secondary"
position="right"
onClick={handleXButtonClick}
type="button"
disabled={isSubmitting}
/>
</StyledButtonContainer>
)}
</StyledRow>
<ConfigVariableHelpText
variable={variable}
hasValueChanged={hasValueChanged}
/>
</Form>
</StyledFormContainer>
</SettingsPageContainer>
</SubMenuTopBarContainer>
<ConfirmationModal
modalInstanceId={RESET_VARIABLE_MODAL_ID}
title={t`Reset variable`}
subtitle={t`This will revert the database value to environment/default value. The database override will be removed and the system will use the environment settings.`}
onConfirmClick={handleConfirmReset}
confirmButtonText={t`Reset`}
confirmButtonAccent="danger"
<SubMenuTopBarContainer
links={[
{
children: t`Other`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel - Config`,
href: getSettingsPath(
SettingsPath.AdminPanel,
undefined,
undefined,
'config-variables',
),
},
{
children: variable.name,
},
]}
>
<ConfigVariableEdit
title={variable.name}
description={variable.description}
input={
<ConfigVariableValueInput
variable={variable}
value={value}
onChange={setValue}
disabled={isEnvOnly || !isEditing}
/>
}
isEditing={isEditing}
setIsEditing={setIsEditing}
isSaveDisabled={!isValueValid}
onSave={onSave}
onCancel={onCancel}
canOpenCancelModal={canOpenCancelModal}
onEdit={onEdit}
onConfirmReset={onConfirmReset}
editDisabled={isEnvOnly || !isConfigVariablesInDbEnabled}
helpContent={
<ConfigVariableHelpText
variable={variable}
hasValueChanged={hasValueChanged}
/>
}
/>
</>
</SubMenuTopBarContainer>
);
};
@@ -27,7 +27,7 @@ import type { SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
const APPLICATION_DETAIL_ID = 'application-detail-id';
export const SettingsApplicationDetails = () => {
export const SettingsApplicationDetailsToRemove = () => {
const { applicationId = '' } = useParams<{ applicationId: string }>();
const activeTabId = useAtomComponentStateValue(
@@ -38,10 +38,10 @@ import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useQuery } from '@apollo/client/react';
import {
PermissionFlagType,
FindOneApplicationByUniversalIdentifierDocument,
FindMarketplaceAppDetailDocument,
ApplicationRegistrationSourceType,
FindMarketplaceAppDetailDocument,
FindOneApplicationByUniversalIdentifierDocument,
PermissionFlagType,
} from '~/generated-metadata/graphql';
import { SettingsApplicationPermissionsTab } from '~/pages/settings/applications/tabs/SettingsApplicationPermissionsTab';
import { SettingsAvailableApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsAvailableApplicationDetailContentTab';
@@ -179,9 +179,9 @@ const StyledSectionTitle = styled.h2`
const StyledAboutContainer = styled.div``;
export const SettingsAvailableApplicationDetails = () => {
const { availableApplicationId = '' } = useParams<{
availableApplicationId: string;
export const SettingsApplicationPage = () => {
const { applicationUniversalIdentifier = '' } = useParams<{
applicationUniversalIdentifier: string;
}>();
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0);
@@ -195,14 +195,14 @@ export const SettingsAvailableApplicationDetails = () => {
const { data: applicationData } = useQuery(
FindOneApplicationByUniversalIdentifierDocument,
{
variables: { universalIdentifier: availableApplicationId },
skip: !availableApplicationId,
variables: { universalIdentifier: applicationUniversalIdentifier },
skip: !applicationUniversalIdentifier,
},
);
const { data: detailData } = useQuery(FindMarketplaceAppDetailDocument, {
variables: { universalIdentifier: availableApplicationId },
skip: !availableApplicationId,
variables: { universalIdentifier: applicationUniversalIdentifier },
skip: !applicationUniversalIdentifier,
});
const application = applicationData?.findOneApplication;
@@ -3,29 +3,90 @@ 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 { FindOneApplicationRegistrationDocument } from '~/generated-metadata/graphql';
import { SettingsApplicationRegistrationContent } from '~/pages/settings/applications/components/SettingsApplicationRegistrationContent';
import { FindApplicationRegistrationByUniversalIdentifierDocument } from '~/generated-metadata/graphql';
import { useLingui } from '@lingui/react/macro';
import { Tag } from 'twenty-ui/components';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import {
IconInfoCircle,
IconKey,
IconSettings,
IconWorld,
} from 'twenty-ui/display';
import { SettingsApplicationRegistrationConfigTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationConfigTab';
import { SettingsApplicationRegistrationOAuthTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationOAuthTab';
import { SettingsApplicationRegistrationDistributionTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationDistributionTab';
import { SettingsApplicationRegistrationGeneralTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationGeneralTab';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
const REGISTRATION_DETAIL_TAB_LIST_ID =
'application-registration-detail-tab-list';
export const SettingsApplicationRegistrationDetails = () => {
const { t } = useLingui();
const { applicationRegistrationId = '' } = useParams<{
applicationRegistrationId: string;
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
REGISTRATION_DETAIL_TAB_LIST_ID,
);
const { applicationUniversalIdentifier = '' } = useParams<{
applicationUniversalIdentifier: string;
}>();
const { data, loading } = useQuery(FindOneApplicationRegistrationDocument, {
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
});
const { data, loading } = useQuery(
FindApplicationRegistrationByUniversalIdentifierDocument,
{
variables: { universalIdentifier: applicationUniversalIdentifier },
skip: !applicationUniversalIdentifier,
},
);
const registration = data?.findOneApplicationRegistration;
const registration = data?.findApplicationRegistrationByUniversalIdentifier;
if (loading || !isDefined(registration)) {
return null;
}
const tabs = [
{ 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
registration={registration}
/>
);
case 'distribution':
return (
<SettingsApplicationRegistrationDistributionTab
registration={registration}
/>
);
case 'general':
default:
return (
<SettingsApplicationRegistrationGeneralTab
registration={registration}
/>
);
}
};
return (
<SubMenuTopBarContainer
title={registration.name}
@@ -47,7 +108,13 @@ export const SettingsApplicationRegistrationDetails = () => {
{ children: registration.name },
]}
>
<SettingsApplicationRegistrationContent registration={registration} />
<SettingsPageContainer>
<TabList
tabs={tabs}
componentInstanceId={REGISTRATION_DETAIL_TAB_LIST_ID}
/>
{renderActiveTabContent()}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -2,72 +2,48 @@ import { useLingui } from '@lingui/react/macro';
import { useParams } from 'react-router-dom';
import { useState } from 'react';
import {
FindApplicationRegistrationByUniversalIdentifierDocument,
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 { ConfigVariableEdit } from '@/settings/config-variables/components/ConfigVariableEdit';
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;
`;
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
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 { variableKey } = useParams();
const [value, setValue] = useState<string>('');
const [isEditing, setIsEditing] = useState(false);
const { applicationUniversalIdentifier = '' } = useParams<{
applicationUniversalIdentifier: string;
}>();
const { data: applicationRegistrationData } = useQuery(
FindOneApplicationRegistrationDocument,
FindApplicationRegistrationByUniversalIdentifierDocument,
{
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
variables: { universalIdentifier: applicationUniversalIdentifier },
skip: !applicationUniversalIdentifier,
},
);
const registration =
applicationRegistrationData?.findOneApplicationRegistration;
applicationRegistrationData?.findApplicationRegistrationByUniversalIdentifier;
const { data: variablesData } = useQuery(
FindApplicationRegistrationVariablesDocument,
{
variables: { applicationRegistrationId },
skip: !applicationRegistrationId,
variables: { applicationRegistrationId: registration?.id ?? '' },
skip: !registration?.id,
},
);
@@ -83,65 +59,56 @@ export const SettingsApplicationRegistrationConfigVariableDetail = () => {
);
if (!variable || !registration) {
return null;
return <SettingsSkeletonLoader />;
}
const handleXButtonClick = () => {
if (!isEditing) {
openModal(RESET_VARIABLE_MODAL_ID);
return;
}
const canOpenCancelModal = variable.isFilled && !isNonEmptyString(value);
const onCancel = () => {
setValue('');
setIsEditing(false);
};
const handleSaveVariableValue = async ({
resetValue,
}: {
resetValue?: boolean;
}) => {
const variableKey = variable.key;
if (!isNonEmptyString(value) && !resetValue) {
const onSave = async () => {
if (!isNonEmptyString(value)) {
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 });
const onConfirmReset = async () => {
try {
await updateVariable({
variables: {
input: {
id: variable.id,
update: {
value: '',
resetValue: true,
},
},
},
});
} finally {
setValue('');
}
};
return (
<SubMenuTopBarContainer
title={registration.name}
tag={<Tag text={t`Owner`} color={'gray'} />}
links={[
{
children: t`Workspace`,
@@ -160,7 +127,7 @@ export const SettingsApplicationRegistrationConfigVariableDetail = () => {
children: t`${registration.name} - Config`,
href: getSettingsPath(
SettingsPath.ApplicationRegistrationDetail,
{ applicationRegistrationId },
{ applicationUniversalIdentifier },
undefined,
'config',
),
@@ -170,66 +137,29 @@ export const SettingsApplicationRegistrationConfigVariableDetail = () => {
},
]}
>
<SettingsPageContainer>
<Section>
<H3Title
title={
<>
{variable.key}
{variable.isRequired && (
<span style={{ color: 'red' }}> *</span>
)}
</>
<ConfigVariableEdit
title={variable.key}
description={variable.description}
input={
<TextInput
value={value}
placeholder={
variable.isFilled
? '••••••••••••••••••••••••'
: t`set-config-value`
}
description={variable.description}
onChange={setValue}
disabled={!isEditing}
fullWidth
/>
</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"
}
isEditing={isEditing}
setIsEditing={setIsEditing}
isSaveDisabled={!isNonEmptyString(value)}
onSave={onSave}
onCancel={onCancel}
canOpenCancelModal={canOpenCancelModal}
onConfirmReset={onConfirmReset}
/>
</SubMenuTopBarContainer>
);
@@ -1,19 +0,0 @@
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`} />
);
};
@@ -1,81 +0,0 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
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,
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: ApplicationRegistration;
};
export const SettingsApplicationRegistrationContent = ({
registration,
}: SettingsApplicationRegistrationContentProps) => {
const { t } = useLingui();
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
REGISTRATION_DETAIL_TAB_LIST_ID,
);
const tabs = [
{ 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
registration={registration}
/>
);
case 'distribution':
return (
<SettingsApplicationRegistrationDistributionTab
registration={registration}
/>
);
case 'general':
default:
return (
<SettingsApplicationRegistrationGeneralTab
registration={registration}
/>
);
}
};
return (
<SettingsPageContainer>
<TabList
tabs={tabs}
componentInstanceId={REGISTRATION_DETAIL_TAB_LIST_ID}
/>
{renderActiveTabContent()}
</SettingsPageContainer>
);
};
@@ -1,13 +1,10 @@
import {
H2Title,
IconBox,
IconBrandDocker,
IconChartBar,
IconDownload,
IconStatusChange,
IconGitBranch,
IconTag,
IconWorld,
IconGitBranch,
} from 'twenty-ui/display';
import { Trans, useLingui } from '@lingui/react/macro';
import {
@@ -18,7 +15,6 @@ import {
type ApplicationRegistration,
ApplicationRegistrationSourceType,
ApplicationRegistrationTarballUrlDocument,
FindApplicationRegistrationStatsDocument,
FindOneApplicationSummaryDocument,
GetPublicWorkspaceDataByIdDocument,
} from '~/generated-metadata/graphql';
@@ -59,7 +55,7 @@ const StyledGeneralContainer = styled.div`
gap: ${themeCssVariables.spacing[2]};
`;
export const SettingsAdminApplicationRegistrationDetailContent = ({
export const SettingsApplicationRegistrationGeneralInfo = ({
registration,
}: {
registration: ApplicationRegistration;
@@ -96,16 +92,8 @@ export const SettingsAdminApplicationRegistrationDetailContent = ({
},
);
const { data: statsData } = useQuery(
FindApplicationRegistrationStatsDocument,
{
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const shareLink = getSettingsPath(SettingsPath.AvailableApplicationDetail, {
availableApplicationId: registration.universalIdentifier,
const shareLink = getSettingsPath(SettingsPath.ApplicationDetail, {
applicationUniversalIdentifier: registration.universalIdentifier,
});
const ownerWorkspace = ownerWorkspaceData?.getPublicWorkspaceDataById;
@@ -206,69 +194,24 @@ export const SettingsAdminApplicationRegistrationDetailContent = ({
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>
)}
</>
<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>
);
};
@@ -0,0 +1,80 @@
import {
H2Title,
IconBrandDocker,
IconChartBar,
IconStatusChange,
} from 'twenty-ui/display';
import { useLingui } from '@lingui/react/macro';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import {
type ApplicationRegistration,
FindApplicationRegistrationStatsDocument,
} from '~/generated-metadata/graphql';
import { useQuery } from '@apollo/client/react';
import { Section } from 'twenty-ui/layout';
export const SettingsApplicationRegistrationGeneralStats = ({
registration,
}: {
registration: ApplicationRegistration;
}) => {
const { t } = useLingui();
const applicationRegistrationId = registration.id;
const { data: statsData } = useQuery(
FindApplicationRegistrationStatsDocument,
{
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const stats = statsData?.findApplicationRegistrationStats;
const hasStats = (stats?.activeInstalls ?? 0) > 0;
if (!hasStats) {
return null;
}
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`Install Stats`}
description={t`Usage across all workspaces on this server`}
/>
<SettingsTableCard
rounded
items={statsItems}
gridAutoColumns="200px 1fr"
/>
</Section>
);
};
@@ -95,7 +95,8 @@ export const SettingsApplicationsTable = ({
/>
}
link={getSettingsPath(SettingsPath.ApplicationDetail, {
applicationId: application.id,
applicationUniversalIdentifier:
application.universalIdentifier,
})}
/>
);
@@ -48,8 +48,8 @@ export const SettingsAvailableApplicationCard = ({
return (
<StyledLinkContainer>
<Link
to={getSettingsPath(SettingsPath.AvailableApplicationDetail, {
availableApplicationId: application.id,
to={getSettingsPath(SettingsPath.ApplicationDetail, {
applicationUniversalIdentifier: application.universalIdentifier,
})}
>
<Card rounded fullWidth>
@@ -5,34 +5,17 @@ import {
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 { H2Title, Status } from 'twenty-ui/display';
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};
`;
import { ConfigVariableTable } from '@/settings/config-variables/components/ConfigVariableTable';
export const SettingsApplicationRegistrationConfigTab = ({
registration,
}: {
registration: ApplicationRegistrationData;
}) => {
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
const applicationRegistrationId = registration.id;
@@ -48,6 +31,23 @@ export const SettingsApplicationRegistrationConfigTab = ({
const variables: ApplicationRegistrationVariable[] =
variablesData?.findApplicationRegistrationVariables ?? [];
const configVariables = variables.map((variable) => ({
name: variable.key,
description: variable.description,
value: variable.isFilled ? (
'••••••••••'
) : (
<Status color="gray" text={t`Not set`} />
),
to: getSettingsPath(
SettingsPath.ApplicationRegistrationConfigVariableDetails,
{
applicationUniversalIdentifier: registration.universalIdentifier,
variableKey: variable.key,
},
),
}));
return (
variables.length > 0 && (
<Section>
@@ -55,71 +55,7 @@ export const SettingsApplicationRegistrationConfigTab = ({
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>
<ConfigVariableTable configVariables={configVariables} />
</Section>
)
);
@@ -24,8 +24,8 @@ export const SettingsApplicationRegistrationDistributionTab = ({
const isTarballSource =
registration.sourceType === ApplicationRegistrationSourceType.TARBALL;
const shareLink = getSettingsPath(SettingsPath.AvailableApplicationDetail, {
availableApplicationId: registration.universalIdentifier,
const shareLink = getSettingsPath(SettingsPath.ApplicationDetail, {
applicationUniversalIdentifier: registration.universalIdentifier,
});
const publishCommands = ['yarn twenty publish'];
@@ -1,252 +1,32 @@
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 { 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 {
H1Title,
H1TitleFontColor,
H2Title,
IconShare,
IconTrash,
AppTooltip,
TooltipDelay,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section, SectionAlignment, SectionFontColor } from 'twenty-ui/layout';
import {
type ApplicationRegistration,
DeleteApplicationRegistrationDocument,
FindApplicationRegistrationStatsDocument,
FindManyApplicationRegistrationsDocument,
TransferApplicationRegistrationOwnershipDocument,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
StyledAppModal,
StyledAppModalButton,
StyledAppModalSection,
StyledAppModalTitle,
} from '~/pages/settings/applications/components/SettingsAppModalLayout';
import { SettingsAdminApplicationRegistrationDetailContent } from '~/pages/settings/admin-panel/SettingsAdminApplicationRegistrationDetailContent';
import { isDefined } from 'twenty-shared/utils';
import { type ApplicationRegistration } from '~/generated-metadata/graphql';
const DELETE_REGISTRATION_MODAL_ID = 'delete-application-registration-modal';
import { SettingsApplicationRegistrationGeneralInfo } from '~/pages/settings/applications/components/SettingsApplicationRegistrationGeneralInfo';
const TRANSFER_OWNERSHIP_MODAL_ID =
'transfer-application-registration-ownership-modal';
const DELETE_REGISTRATION_BUTTON_ID = 'delete-registration-button';
const StyledDangerButtonGroup = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
import { SettingsAdminApplicationRegistrationDangerZone } from '~/pages/settings/admin-panel/SettingsAdminApplicationRegistrationDangerZone';
import { SettingsApplicationRegistrationGeneralStats } from '~/pages/settings/applications/components/SettingsApplicationRegistrationGeneralStats';
import { SettingsAdminApplicationRegistrationGeneralToggles } from '~/pages/settings/admin-panel/SettingsAdminApplicationRegistrationGeneralToggles';
export const SettingsApplicationRegistrationGeneralTab = ({
registration,
displayAdminToggles,
}: {
registration: ApplicationRegistration;
displayAdminToggles?: boolean;
}) => {
const { t } = useLingui();
const navigate = useNavigateSettings();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { openModal, closeModal } = useModal();
const [isLoading, setIsLoading] = useState(false);
const [isTransferring, setIsTransferring] = useState(false);
const [transferSubdomain, setTransferSubdomain] = useState('');
const applicationRegistrationId = registration.id;
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,
{
refetchQueries: [FindManyApplicationRegistrationsDocument],
},
);
const [transferOwnership] = useMutation(
TransferApplicationRegistrationOwnershipDocument,
{
refetchQueries: [FindManyApplicationRegistrationsDocument],
},
);
const handleDelete = async () => {
setIsLoading(true);
try {
await deleteRegistration({
variables: { id: applicationRegistrationId },
});
navigate(SettingsPath.Applications);
} catch {
enqueueErrorSnackBar({
message: t`Error deleting app`,
});
} finally {
setIsLoading(false);
}
};
const handleTransferOwnership = async () => {
const trimmed = transferSubdomain.trim();
if (!isNonEmptyString(trimmed)) {
return;
}
setIsTransferring(true);
try {
await transferOwnership({
variables: {
applicationRegistrationId,
targetWorkspaceSubdomain: trimmed,
},
});
enqueueSuccessSnackBar({
message: t`Ownership transferred successfully`,
});
setTransferSubdomain('');
navigate(SettingsPath.Applications);
} catch {
enqueueErrorSnackBar({
message: t`Failed to transfer ownership. Check that the subdomain is correct.`,
});
} finally {
setIsTransferring(false);
}
};
const confirmationValue = t`yes`;
return (
<>
<SettingsAdminApplicationRegistrationDetailContent
<SettingsApplicationRegistrationGeneralInfo registration={registration} />
{displayAdminToggles && (
<SettingsAdminApplicationRegistrationGeneralToggles
registration={registration}
/>
)}
<SettingsApplicationRegistrationGeneralStats
registration={registration}
/>
<Section>
<H2Title
title={t`Danger zone`}
description={t`Delete or transfer this app registration`}
/>
<StyledDangerButtonGroup>
<Button
id={DELETE_REGISTRATION_BUTTON_ID}
accent="danger"
variant="secondary"
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={IconShare}
onClick={() => openModal(TRANSFER_OWNERSHIP_MODAL_ID)}
/>
</StyledDangerButtonGroup>
</Section>
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalInstanceId={DELETE_REGISTRATION_MODAL_ID}
title={t`Delete app`}
subtitle={
<Trans>
Please type {`"${confirmationValue}"`} to confirm you want to delete
this app. All workspace installations linked to it will lose their
OAuth credentials.
</Trans>
}
onConfirmClick={handleDelete}
confirmButtonText={t`Delete`}
loading={isLoading}
<SettingsAdminApplicationRegistrationDangerZone
registration={registration}
/>
<StyledAppModal
modalId={TRANSFER_OWNERSHIP_MODAL_ID}
isClosable
onClose={() => setTransferSubdomain('')}
padding="large"
dataGloballyPreventClickOutside
>
<StyledAppModalTitle>
<H1Title
title={t`Transfer ownership`}
fontColor={H1TitleFontColor.Primary}
/>
</StyledAppModalTitle>
<StyledAppModalSection
alignment={SectionAlignment.Center}
fontColor={SectionFontColor.Primary}
>
{t`Enter the workspace subdomain to transfer this app to. You will lose access to manage it.`}
</StyledAppModalSection>
<Section>
<SettingsTextInput
instanceId="transfer-ownership-subdomain"
value={transferSubdomain}
onChange={setTransferSubdomain}
placeholder={t`e.g. my-workspace`}
fullWidth
disableHotkeys
label={t`Target workspace subdomain`}
autoFocusOnMount
/>
</Section>
<StyledAppModalButton
onClick={() => {
closeModal(TRANSFER_OWNERSHIP_MODAL_ID);
setTransferSubdomain('');
}}
variant="secondary"
title={t`Cancel`}
fullWidth
/>
<StyledAppModalButton
onClick={handleTransferOwnership}
variant="secondary"
accent="danger"
title={t`Transfer`}
disabled={
!isNonEmptyString(transferSubdomain.trim()) || isTransferring
}
fullWidth
/>
</StyledAppModal>
</>
);
};
@@ -20,11 +20,11 @@ import {
Avatar,
CommandBlock,
H2Title,
IconArrowUpRight,
IconChevronRight,
IconCopy,
IconArrowUpRight,
OverflowingTextWithTooltip,
InlineBanner,
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { Button, SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
@@ -121,7 +121,7 @@ export const SettingsApplicationsDeveloperTab = () => {
registration: ApplicationRegistrationFragmentFragment,
) =>
getSettingsPath(SettingsPath.ApplicationRegistrationDetail, {
applicationRegistrationId: registration.id,
applicationUniversalIdentifier: registration.universalIdentifier,
});
return (
@@ -232,12 +232,10 @@ export const SettingsApplicationsDeveloperTab = () => {
<TableRow
key={application.id}
gridAutoColumns={NPM_PACKAGES_GRID_COLUMNS}
to={getSettingsPath(
SettingsPath.AvailableApplicationDetail,
{
availableApplicationId: application.id,
},
)}
to={getSettingsPath(SettingsPath.ApplicationDetail, {
applicationUniversalIdentifier:
application.universalIdentifier,
})}
>
<StyledNameTableCell>
<Avatar
@@ -3,6 +3,7 @@ import { type Application } from '~/generated-metadata/graphql';
export type ApplicationWithoutRelation = Pick<
Application,
| 'id'
| 'universalIdentifier'
| 'name'
| 'description'
| 'version'
@@ -20,7 +20,7 @@ import {
IconSettings,
} from 'twenty-ui/display';
import { useQuery } from '@apollo/client/react';
import { FindOneApplicationDocument } from '~/generated-metadata/graphql';
import { FindOneApplicationByUniversalIdentifierDocument } from '~/generated-metadata/graphql';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { SettingsLogicFunctionCodeEditorTab } from '@/settings/logic-functions/components/tabs/SettingsLogicFunctionCodeEditorTab';
@@ -29,25 +29,30 @@ import { useExecuteLogicFunction } from '@/logic-functions/hooks/useExecuteLogic
const LOGIC_FUNCTION_DETAIL_ID = 'logic-function-detail';
export const SettingsLogicFunctionDetail = () => {
const { logicFunctionId = '', applicationId = '' } = useParams();
const { logicFunctionId = '', applicationUniversalIdentifier = '' } =
useParams();
const navigate = useNavigate();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { data, loading: applicationLoading } = useQuery(
FindOneApplicationDocument,
const { data: applicationData, loading: applicationLoading } = useQuery(
FindOneApplicationByUniversalIdentifierDocument,
{
variables: { id: applicationId },
skip: !applicationId,
variables: { universalIdentifier: applicationUniversalIdentifier },
skip: !applicationUniversalIdentifier,
},
);
const applicationName = data?.findOneApplication?.name;
const application = applicationData?.findOneApplication;
if (!application) {
return null;
}
const workspaceCustomApplicationId =
currentWorkspace?.workspaceCustomApplication?.id;
const isManaged = applicationId !== workspaceCustomApplicationId;
const isManaged = application.id !== workspaceCustomApplicationId;
const instanceId = `${LOGIC_FUNCTION_DETAIL_ID}-${logicFunctionId}`;
@@ -88,7 +93,7 @@ export const SettingsLogicFunctionDetail = () => {
const isTestTab = activeTabId === 'test';
const breadcrumbLinks =
isDefined(applicationId) && applicationId !== ''
isDefined(application.id) && application.id !== ''
? [
{
children: t`Workspace`,
@@ -99,11 +104,11 @@ export const SettingsLogicFunctionDetail = () => {
href: getSettingsPath(SettingsPath.Applications),
},
{
children: `${applicationName}`,
children: `${application.name} - ${t`Content`}`,
href: getSettingsPath(
SettingsPath.ApplicationDetail,
{
applicationId,
applicationUniversalIdentifier: application.universalIdentifier,
},
undefined,
'content',
@@ -15,6 +15,11 @@ export class MarketplaceAppDTO {
@Field()
id: string;
@IsString()
@IsNotEmpty()
@Field()
universalIdentifier: string;
@IsString()
@IsNotEmpty()
@Field()
@@ -84,7 +84,8 @@ export class MarketplaceQueryService {
const app = registration.manifest?.application;
return {
id: registration.universalIdentifier,
id: registration.id,
universalIdentifier: registration.universalIdentifier,
name: app?.displayName ?? registration.name,
description: app?.description ?? '',
icon: app?.icon ?? 'IconApps',
@@ -26,9 +26,18 @@ export class ApplicationRegistrationVariableService {
) {}
async findVariables(
applicationRegistrationId: string,
applicationUniversalIdentifier: string,
workspaceId: string,
): Promise<ApplicationRegistrationVariableEntity[]> {
const applicationRegistration =
await this.applicationRegistrationRepository.findOneOrFail({
where: {
universalIdentifier: applicationUniversalIdentifier,
},
});
const applicationRegistrationId = applicationRegistration.id;
await this.assertRegistrationOwnedByWorkspace(
applicationRegistrationId,
workspaceId,
@@ -30,24 +30,6 @@ export class ApplicationService {
private readonly workspaceRepository: Repository<WorkspaceEntity>,
) {}
async findApplicationRoleId(
applicationId: string,
workspaceId: string,
): Promise<string> {
const application = await this.applicationRepository.findOne({
where: { id: applicationId, workspaceId },
});
if (!isDefined(application) || !isDefined(application.defaultRoleId)) {
throw new ApplicationException(
`Could not find application ${applicationId}`,
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
return application.defaultRoleId;
}
async findWorkspaceTwentyStandardAndCustomApplicationOrThrow({
workspace: workspaceInput,
workspaceId,
@@ -41,11 +41,10 @@ export enum SettingsPath {
AISkillDetail = 'ai/skills/:skillId',
AIToolDetail = 'ai/tools/:toolIdentifier',
Applications = 'applications',
ApplicationDetail = 'applications/:applicationId',
ApplicationLogicFunctionDetail = 'applications/:applicationId/logicFunctions/:logicFunctionId',
AvailableApplicationDetail = 'applications/available/:availableApplicationId',
ApplicationRegistrationDetail = 'applications/registrations/:applicationRegistrationId',
ApplicationRegistrationConfigVariableDetails = 'applications/registrations/:applicationRegistrationId/config-variables/:variableKey',
ApplicationLogicFunctionDetail = 'applications/:applicationUniversalIdentifier/logicFunctions/:logicFunctionId',
ApplicationDetail = 'applications/:applicationUniversalIdentifier',
ApplicationRegistrationDetail = 'applications/registrations/:applicationUniversalIdentifier',
ApplicationRegistrationConfigVariableDetails = 'applications/registrations/:applicationUniversalIdentifier/config-variables/:variableKey',
LogicFunctions = 'functions',
NewLogicFunction = 'functions/new',
LogicFunctionDetail = 'functions/:logicFunctionId',
@@ -72,7 +71,7 @@ export enum SettingsPath {
AdminPanelNewAiModel = 'admin-panel/ai/providers/:providerName/new-model',
AdminPanelUserDetail = 'admin-panel/users/:userId',
AdminPanelWorkspaceDetail = 'admin-panel/workspaces/:workspaceId',
AdminPanelApplicationRegistrationDetail = 'admin-panel/applications/registrations/:applicationRegistrationId',
AdminPanelApplicationRegistrationDetail = 'admin-panel/applications/registrations/:applicationUniversalIdentifier',
AdminPanelWorkspaceChatThread = 'admin-panel/workspaces/:workspaceId/threads/:threadId',
Roles = 'roles',