Files
twenty_crm/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx
Raphaël Bosi 6554947671 Modal API Refactoring (#12062)
# Modal API Refactoring

This PR refactors the modal system to use an imperative approach for
setting hotkey scopes, addressing race conditions that occurred with the
previous effect-based implementation.

Fixes #11986
Closes #12087

## Key Changes:

- **New Modal API**: Introduced a `useModal` hook with `openModal`,
`closeModal`, and `toggleModal` functions, similar to the existing
dropdown API
- **Modal Identification**: Added a `modalId` prop to uniquely identify
modals
- **State Management**: Introduced `isModalOpenedComponentState` and
removed individual boolean state atoms (like
`isRemoveSortingModalOpenState`)
- **Modal Constants**: Added consistent modal ID constants (e.g.,
`FavoriteFolderDeleteModalId`, `RecordIndexRemoveSortingModalId`) for
better maintainability
- **Mount Effects**: Created mount effect components (like
`AuthModalMountEffect`) to handle initial modal opening where needed

## Implementation Details:

- Modified `Modal` and `ConfirmationModal` components to accept the new
`modalId` prop
- Added a component-state-based approach using
`ModalComponentInstanceContext` to track modal state
- Introduced imperative modal handlers that properly manage hotkey
scopes
- Components like `ActionModal` and `AttachmentList` now use the new
`useModal` hook for better control over modal state

## Benefits:

- **Race Condition Prevention**: Hotkey scopes are now set imperatively,
eliminating race conditions
- **Consistent API**: Modal and dropdown now share similar patterns,
improving developer experience

## Tests to do before merging:

1. Action Modals (Modal triggered by an action, for example the delete
action)

2. Auth Modal (`AuthModal.tsx` and `AuthModalMountEffect.tsx`)
   - Test that auth modal opens automatically on mount
   - Verify authentication flow works properly

3. Email Verification Sent Modal (in `SignInUp.tsx`)
   - Verify this modal displays correctly

4. Attachment Preview Modal (in `AttachmentList.tsx`)
   - Test opening preview modal by clicking on attachments
   - Verify close, download functionality works
   - Test modal navigation and interactions

5. Favorite Folder Delete Modal (`CurrentWorkspaceMemberFavorites.tsx`)
   - Test deletion confirmation flow
- Check that modal opens when attempting to delete folders with
favorites

6. Record Board Remove Sorting Modal (`RecordBoard.tsx` using
`RecordIndexRemoveSortingModalId`)
- Test that modal appears when trying to drag records with sorting
enabled
   - Verify sorting removal works correctly

7. Record Group Reorder Confirmation Modal
(`RecordGroupReorderConfirmationModal.tsx`)
   - Test group reordering with sorting enabled
   - Verify confirmation modal properly handles sorting removal

8. Confirmation Modal (base component used by several modals)
   - Test all variants with different confirmation options

For each modal, verify:
- Opening/closing behavior
- Hotkey support (Esc to close, Enter to confirm where applicable)
- Click outside behavior
- Proper z-index stacking
- Any modal-specific functionality
2025-05-16 15:04:22 +00:00

288 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { DateTime } from 'luxon';
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput';
import { ApiKeyNameInput } from '@/settings/developers/components/ApiKeyNameInput';
import { apiKeyTokenFamilyState } from '@/settings/developers/states/apiKeyTokenFamilyState';
import { ApiKey } from '@/settings/developers/types/api-key/ApiKey';
import { computeNewExpirationDate } from '@/settings/developers/utils/computeNewExpirationDate';
import { formatExpiration } from '@/settings/developers/utils/formatExpiration';
import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
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 { Trans, useLingui } from '@lingui/react/macro';
import { H2Title, IconRepeat, IconTrash } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useGenerateApiKeyTokenMutation } from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledInfo = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
`;
const StyledInputContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const DELETE_API_KEY_MODAL_ID = 'delete-api-key-modal';
const REGENERATE_API_KEY_MODAL_ID = 'regenerate-api-key-modal';
export const SettingsDevelopersApiKeyDetail = () => {
const { t } = useLingui();
const { enqueueSnackBar } = useSnackBar();
const { openModal } = useModal();
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigateSettings();
const { apiKeyId = '' } = useParams();
const apiKeyToken = useRecoilValue(apiKeyTokenFamilyState(apiKeyId));
const setApiKeyTokenCallback = useRecoilCallback(
({ set }) =>
(apiKeyId: string, token: string) => {
set(apiKeyTokenFamilyState(apiKeyId), token);
},
[],
);
const [generateOneApiKeyToken] = useGenerateApiKeyTokenMutation();
const { createOneRecord: createOneApiKey } = useCreateOneRecord<ApiKey>({
objectNameSingular: CoreObjectNameSingular.ApiKey,
});
const { updateOneRecord: updateApiKey } = useUpdateOneRecord<ApiKey>({
objectNameSingular: CoreObjectNameSingular.ApiKey,
});
const [apiKeyName, setApiKeyName] = useState('');
const { record: apiKeyData, loading } = useFindOneRecord({
objectNameSingular: CoreObjectNameSingular.ApiKey,
objectRecordId: apiKeyId,
onCompleted: (record) => {
setApiKeyName(record.name);
},
});
const deleteIntegration = async (redirect = true) => {
setIsLoading(true);
try {
await updateApiKey?.({
idToUpdate: apiKeyId,
updateOneRecordInput: { revokedAt: DateTime.now().toString() },
});
if (redirect) {
navigate(SettingsPath.APIs);
}
} catch (err) {
enqueueSnackBar(t`Error deleting api key: ${err}`, {
variant: SnackBarVariant.Error,
});
} finally {
setIsLoading(false);
}
};
const createIntegration = async (
name: string,
newExpiresAt: string | null,
) => {
const newApiKey = await createOneApiKey?.({
name: name,
expiresAt: newExpiresAt ?? '',
});
if (!newApiKey) {
return;
}
const tokenData = await generateOneApiKeyToken({
variables: {
apiKeyId: newApiKey.id,
expiresAt: newApiKey?.expiresAt,
},
});
return {
id: newApiKey.id,
token: tokenData.data?.generateApiKeyToken.token,
};
};
const regenerateApiKey = async () => {
setIsLoading(true);
try {
if (isNonEmptyString(apiKeyData?.name)) {
const newExpiresAt = computeNewExpirationDate(
apiKeyData?.expiresAt,
apiKeyData?.createdAt,
);
const apiKey = await createIntegration(apiKeyData?.name, newExpiresAt);
await deleteIntegration(false);
if (isNonEmptyString(apiKey?.token)) {
setApiKeyTokenCallback(apiKey.id, apiKey.token);
navigate(SettingsPath.DevelopersApiKeyDetail, {
apiKeyId: apiKey.id,
});
}
}
} catch (err) {
enqueueSnackBar(t`Error regenerating api key: ${err}`, {
variant: SnackBarVariant.Error,
});
} finally {
setIsLoading(false);
}
};
const confirmationValue = t`yes`;
return (
<>
{apiKeyData?.name && (
<SubMenuTopBarContainer
title={apiKeyData?.name}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`APIs`,
href: getSettingsPath(SettingsPath.APIs),
},
{ children: t`${apiKeyName} API Key` },
]}
>
<SettingsPageContainer>
<Section>
{apiKeyToken ? (
<>
<H2Title
title={t`API Key`}
description={t`Copy this key as it will not be visible again`}
/>
<ApiKeyInput apiKey={apiKeyToken} />
</>
) : (
<>
<H2Title
title={t`API Key`}
description={t`Regenerate an API key`}
/>
<StyledInputContainer>
<Button
title={t`Regenerate Key`}
Icon={IconRepeat}
onClick={() => openModal(REGENERATE_API_KEY_MODAL_ID)}
/>
<StyledInfo>
{formatExpiration(
apiKeyData?.expiresAt || '',
true,
false,
)}
</StyledInfo>
</StyledInputContainer>
</>
)}
</Section>
<Section>
<H2Title title={t`Name`} description={t`Name of your API key`} />
<ApiKeyNameInput
apiKeyName={apiKeyName}
apiKeyId={apiKeyData?.id}
disabled={loading}
onNameUpdate={setApiKeyName}
/>
</Section>
<Section>
<H2Title
title={t`Expiration`}
description={t`When the key will be disabled`}
/>
<TextInput
placeholder={t`E.g. backoffice integration`}
value={formatExpiration(
apiKeyData?.expiresAt || '',
true,
false,
)}
disabled
fullWidth
/>
</Section>
<Section>
<H2Title
title={t`Danger zone`}
description={t`Delete this integration`}
/>
<Button
accent="danger"
variant="secondary"
title={t`Delete`}
Icon={IconTrash}
onClick={() => openModal(DELETE_API_KEY_MODAL_ID)}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
)}
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalId={DELETE_API_KEY_MODAL_ID}
title={t`Delete API key`}
subtitle={
<Trans>
Please type {`"${confirmationValue}"`} to confirm you want to delete
this API Key. Be aware that any script using this key will stop
working.
</Trans>
}
onConfirmClick={deleteIntegration}
confirmButtonText={t`Delete`}
loading={isLoading}
/>
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalId={REGENERATE_API_KEY_MODAL_ID}
title={t`Regenerate an API key`}
subtitle={
<Trans>
If youve lost this key, you can regenerate it, but be aware that
any script using this key will need to be updated. Please type
{`"${confirmationValue}"`} to confirm.
</Trans>
}
onConfirmClick={regenerateApiKey}
confirmButtonText={t`Regenerate key`}
loading={isLoading}
/>
</>
);
};