2060 create a new api key (#2206)

* Add folder for api settings

* Init create api key page

* Update create api key page

* Implement api call to create apiKey

* Add create api key mutation

* Get id when creating apiKey

* Display created Api Key

* Add delete api key button

* Remove button from InputText

* Update stuff

* Add test for ApiDetail

* Fix type

* Use recoil instead of router state

* Remane route paths

* Remove online return

* Move and test date util

* Remove useless Component

* Rename ApiKeys paths

* Rename ApiKeys files

* Add input text info testing

* Rename hooks to webhooks

* Remove console error

* Add tests to reach minimum coverage
This commit is contained in:
martmull
2023-10-24 16:14:54 +02:00
committed by GitHub
parent b6e8fabbb1
commit d61511262e
55 changed files with 919 additions and 133 deletions

View File

@@ -37,7 +37,7 @@ From the `packages/twenty-zapier` folder, run:
```bash
cp .env.example .env
```
Run the application locally, go to [http://localhost:3000/settings/apis](http://localhost:3000/settings/apis), and generate an API key.
Run the application locally, go to [http://localhost:3000/settings/developers/api-keys](http://localhost:3000/settings/developers/api-keys), and generate an API key.
Replace the **YOUR_API_KEY** value in the `.env` file with the API key you just generated.

View File

@@ -107,7 +107,7 @@ module.exports = {
'message': 'Icon imports are only allowed for `@/ui/icon`',
},
{
'group': ['react-hotkeys-hook'],
'group': ['react-hotkeys-web-hook'],
"importNames": ["useHotkeys"],
'message': 'Please use the custom wrapper: `useScopedHotkeys`',
},

View File

@@ -21,6 +21,9 @@ import { SettingsNewObject } from '~/pages/settings/data-model/SettingsNewObject
import { SettingsObjectDetail } from '~/pages/settings/data-model/SettingsObjectDetail';
import { SettingsObjectEdit } from '~/pages/settings/data-model/SettingsObjectEdit';
import { SettingsObjects } from '~/pages/settings/data-model/SettingsObjects';
import { SettingsDevelopersApiKeyDetail } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail';
import { SettingsDevelopersApiKeys } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeys';
import { SettingsDevelopersApiKeysNew } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew';
import { SettingsExperience } from '~/pages/settings/SettingsExperience';
import { SettingsProfile } from '~/pages/settings/SettingsProfile';
import { SettingsWorkspace } from '~/pages/settings/SettingsWorkspace';
@@ -31,7 +34,6 @@ import { getPageTitleFromPath } from '~/utils/title-utils';
import { ObjectTablePage } from './modules/metadata/components/ObjectTablePage';
import { SettingsObjectNewFieldStep1 } from './pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep1';
import { SettingsObjectNewFieldStep2 } from './pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2';
import { SettingsApis } from './pages/settings/SettingsApis';
export const App = () => {
const { pathname } = useLocation();
@@ -97,7 +99,25 @@ export const App = () => {
path={SettingsPath.NewObject}
element={<SettingsNewObject />}
/>
<Route path={SettingsPath.Apis} element={<SettingsApis />} />
<Route
path={AppPath.DevelopersCatchAll}
element={
<Routes>
<Route
path={SettingsPath.Developers}
element={<SettingsDevelopersApiKeys />}
/>
<Route
path={SettingsPath.DevelopersNewApiKey}
element={<SettingsDevelopersApiKeysNew />}
/>
<Route
path={SettingsPath.DevelopersApiKeyDetail}
element={<SettingsDevelopersApiKeyDetail />}
/>
</Routes>
}
/>
<Route
path={SettingsPath.ObjectNewFieldStep1}
element={<SettingsObjectNewFieldStep1 />}

View File

@@ -760,6 +760,15 @@ export enum ViewType {
Table = 'Table'
}
export type WebHook = {
__typename?: 'WebHook';
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
operation: Scalars['String']['output'];
targetUrl: Scalars['String']['output'];
updatedAt: Scalars['DateTime']['output'];
};
export type Workspace = {
__typename?: 'Workspace';
Attachment?: Maybe<Array<Attachment>>;
@@ -783,6 +792,7 @@ export type Workspace = {
viewFilters?: Maybe<Array<ViewFilter>>;
viewSorts?: Maybe<Array<ViewSort>>;
views?: Maybe<Array<View>>;
webHooks?: Maybe<Array<WebHook>>;
workspaceMember?: Maybe<Array<WorkspaceMember>>;
};

View File

@@ -482,6 +482,13 @@ export enum ApiKeyScalarFieldEnum {
WorkspaceId = 'workspaceId'
}
export type ApiKeyToken = {
__typename?: 'ApiKeyToken';
expiresAt: Scalars['DateTime'];
id: Scalars['String'];
token: Scalars['String'];
};
export type ApiKeyUpdateManyWithoutWorkspaceNestedInput = {
connect?: InputMaybe<Array<ApiKeyWhereUniqueInput>>;
disconnect?: InputMaybe<Array<ApiKeyWhereUniqueInput>>;
@@ -1392,7 +1399,7 @@ export type Mutation = {
createManyViewFilter: AffectedRows;
createManyViewSort: AffectedRows;
createOneActivity: Activity;
createOneApiKey: AuthToken;
createOneApiKey: ApiKeyToken;
createOneComment: Comment;
createOneCompany: Company;
createOneField: Field;
@@ -1402,6 +1409,7 @@ export type Mutation = {
createOnePipelineStage: PipelineStage;
createOneView: View;
createOneViewField: ViewField;
createOneWebHook: WebHook;
deleteCurrentWorkspace: Workspace;
deleteFavorite: Favorite;
deleteManyActivities: AffectedRows;
@@ -1415,6 +1423,7 @@ export type Mutation = {
deleteOneObject: ObjectDeleteResponse;
deleteOnePipelineStage: PipelineStage;
deleteOneView: View;
deleteOneWebHook: WebHook;
deleteUserAccount: User;
deleteWorkspaceMember: WorkspaceMember;
impersonate: Verify;
@@ -1559,6 +1568,11 @@ export type MutationCreateOneViewFieldArgs = {
};
export type MutationCreateOneWebHookArgs = {
data: WebHookCreateInput;
};
export type MutationDeleteFavoriteArgs = {
where: FavoriteWhereInput;
};
@@ -1609,6 +1623,11 @@ export type MutationDeleteOneViewArgs = {
};
export type MutationDeleteOneWebHookArgs = {
where: WebHookWhereUniqueInput;
};
export type MutationDeleteWorkspaceMemberArgs = {
where: WorkspaceMemberWhereUniqueInput;
};
@@ -2523,6 +2542,7 @@ export type Query = {
findManyViewField: Array<ViewField>;
findManyViewFilter: Array<ViewFilter>;
findManyViewSort: Array<ViewSort>;
findManyWebHook: Array<WebHook>;
findManyWorkspaceMember: Array<WorkspaceMember>;
findUniqueCompany: Company;
findUniquePerson: Person;
@@ -2662,6 +2682,16 @@ export type QueryFindManyViewSortArgs = {
};
export type QueryFindManyWebHookArgs = {
cursor?: InputMaybe<WebHookWhereUniqueInput>;
distinct?: InputMaybe<Array<WebHookScalarFieldEnum>>;
orderBy?: InputMaybe<Array<WebHookOrderByWithRelationInput>>;
skip?: InputMaybe<Scalars['Int']>;
take?: InputMaybe<Scalars['Int']>;
where?: InputMaybe<WebHookWhereInput>;
};
export type QueryFindManyWorkspaceMemberArgs = {
cursor?: InputMaybe<WorkspaceMemberWhereUniqueInput>;
distinct?: InputMaybe<Array<WorkspaceMemberScalarFieldEnum>>;
@@ -3392,6 +3422,62 @@ export type ViewWhereUniqueInput = {
id?: InputMaybe<Scalars['String']>;
};
export type WebHook = {
__typename?: 'WebHook';
createdAt: Scalars['DateTime'];
id: Scalars['ID'];
operation: Scalars['String'];
targetUrl: Scalars['String'];
updatedAt: Scalars['DateTime'];
};
export type WebHookCreateInput = {
createdAt?: InputMaybe<Scalars['DateTime']>;
id?: InputMaybe<Scalars['String']>;
operation: Scalars['String'];
targetUrl: Scalars['String'];
updatedAt?: InputMaybe<Scalars['DateTime']>;
};
export type WebHookOrderByWithRelationInput = {
createdAt?: InputMaybe<SortOrder>;
id?: InputMaybe<SortOrder>;
operation?: InputMaybe<SortOrder>;
targetUrl?: InputMaybe<SortOrder>;
updatedAt?: InputMaybe<SortOrder>;
};
export enum WebHookScalarFieldEnum {
CreatedAt = 'createdAt',
DeletedAt = 'deletedAt',
Id = 'id',
Operation = 'operation',
TargetUrl = 'targetUrl',
UpdatedAt = 'updatedAt',
WorkspaceId = 'workspaceId'
}
export type WebHookUpdateManyWithoutWorkspaceNestedInput = {
connect?: InputMaybe<Array<WebHookWhereUniqueInput>>;
disconnect?: InputMaybe<Array<WebHookWhereUniqueInput>>;
set?: InputMaybe<Array<WebHookWhereUniqueInput>>;
};
export type WebHookWhereInput = {
AND?: InputMaybe<Array<WebHookWhereInput>>;
NOT?: InputMaybe<Array<WebHookWhereInput>>;
OR?: InputMaybe<Array<WebHookWhereInput>>;
createdAt?: InputMaybe<DateTimeFilter>;
id?: InputMaybe<StringFilter>;
operation?: InputMaybe<StringFilter>;
targetUrl?: InputMaybe<StringFilter>;
updatedAt?: InputMaybe<DateTimeFilter>;
};
export type WebHookWhereUniqueInput = {
id?: InputMaybe<Scalars['String']>;
};
export type Workspace = {
__typename?: 'Workspace';
Attachment?: Maybe<Array<Attachment>>;
@@ -3415,6 +3501,7 @@ export type Workspace = {
viewFilters?: Maybe<Array<ViewFilter>>;
viewSorts?: Maybe<Array<ViewSort>>;
views?: Maybe<Array<View>>;
webHooks?: Maybe<Array<WebHook>>;
workspaceMember?: Maybe<Array<WorkspaceMember>>;
};
@@ -3590,6 +3677,7 @@ export type WorkspaceUpdateInput = {
viewFilters?: InputMaybe<ViewFilterUpdateManyWithoutWorkspaceNestedInput>;
viewSorts?: InputMaybe<ViewSortUpdateManyWithoutWorkspaceNestedInput>;
views?: InputMaybe<ViewUpdateManyWithoutWorkspaceNestedInput>;
webHooks?: InputMaybe<WebHookUpdateManyWithoutWorkspaceNestedInput>;
workspaceMember?: InputMaybe<WorkspaceMemberUpdateManyWithoutWorkspaceNestedInput>;
};
@@ -4118,6 +4206,27 @@ export type SearchUserQueryVariables = Exact<{
export type SearchUserQuery = { __typename?: 'Query', searchResults: Array<{ __typename?: 'User', avatarUrl?: string | null, id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null }> };
export type DeleteOneApiKeyMutationVariables = Exact<{
apiKeyId: Scalars['String'];
}>;
export type DeleteOneApiKeyMutation = { __typename?: 'Mutation', revokeOneApiKey: { __typename?: 'ApiKey', id: string } };
export type InsertOneApiKeyMutationVariables = Exact<{
data: ApiKeyCreateInput;
}>;
export type InsertOneApiKeyMutation = { __typename?: 'Mutation', createOneApiKey: { __typename?: 'ApiKeyToken', id: string, token: string, expiresAt: string } };
export type GetApiKeyQueryVariables = Exact<{
apiKeyId: Scalars['String'];
}>;
export type GetApiKeyQuery = { __typename?: 'Query', findManyApiKey: Array<{ __typename?: 'ApiKey', id: string, name: string, expiresAt?: string | null }> };
export type UserFieldsFragmentFragment = { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null };
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
@@ -6771,6 +6880,111 @@ export function useSearchUserLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions
export type SearchUserQueryHookResult = ReturnType<typeof useSearchUserQuery>;
export type SearchUserLazyQueryHookResult = ReturnType<typeof useSearchUserLazyQuery>;
export type SearchUserQueryResult = Apollo.QueryResult<SearchUserQuery, SearchUserQueryVariables>;
export const DeleteOneApiKeyDocument = gql`
mutation DeleteOneApiKey($apiKeyId: String!) {
revokeOneApiKey(where: {id: $apiKeyId}) {
id
}
}
`;
export type DeleteOneApiKeyMutationFn = Apollo.MutationFunction<DeleteOneApiKeyMutation, DeleteOneApiKeyMutationVariables>;
/**
* __useDeleteOneApiKeyMutation__
*
* To run a mutation, you first call `useDeleteOneApiKeyMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteOneApiKeyMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [deleteOneApiKeyMutation, { data, loading, error }] = useDeleteOneApiKeyMutation({
* variables: {
* apiKeyId: // value for 'apiKeyId'
* },
* });
*/
export function useDeleteOneApiKeyMutation(baseOptions?: Apollo.MutationHookOptions<DeleteOneApiKeyMutation, DeleteOneApiKeyMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeleteOneApiKeyMutation, DeleteOneApiKeyMutationVariables>(DeleteOneApiKeyDocument, options);
}
export type DeleteOneApiKeyMutationHookResult = ReturnType<typeof useDeleteOneApiKeyMutation>;
export type DeleteOneApiKeyMutationResult = Apollo.MutationResult<DeleteOneApiKeyMutation>;
export type DeleteOneApiKeyMutationOptions = Apollo.BaseMutationOptions<DeleteOneApiKeyMutation, DeleteOneApiKeyMutationVariables>;
export const InsertOneApiKeyDocument = gql`
mutation InsertOneApiKey($data: ApiKeyCreateInput!) {
createOneApiKey(data: $data) {
id
token
expiresAt
}
}
`;
export type InsertOneApiKeyMutationFn = Apollo.MutationFunction<InsertOneApiKeyMutation, InsertOneApiKeyMutationVariables>;
/**
* __useInsertOneApiKeyMutation__
*
* To run a mutation, you first call `useInsertOneApiKeyMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useInsertOneApiKeyMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [insertOneApiKeyMutation, { data, loading, error }] = useInsertOneApiKeyMutation({
* variables: {
* data: // value for 'data'
* },
* });
*/
export function useInsertOneApiKeyMutation(baseOptions?: Apollo.MutationHookOptions<InsertOneApiKeyMutation, InsertOneApiKeyMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<InsertOneApiKeyMutation, InsertOneApiKeyMutationVariables>(InsertOneApiKeyDocument, options);
}
export type InsertOneApiKeyMutationHookResult = ReturnType<typeof useInsertOneApiKeyMutation>;
export type InsertOneApiKeyMutationResult = Apollo.MutationResult<InsertOneApiKeyMutation>;
export type InsertOneApiKeyMutationOptions = Apollo.BaseMutationOptions<InsertOneApiKeyMutation, InsertOneApiKeyMutationVariables>;
export const GetApiKeyDocument = gql`
query GetApiKey($apiKeyId: String!) {
findManyApiKey(where: {id: {equals: $apiKeyId}}) {
id
name
expiresAt
}
}
`;
/**
* __useGetApiKeyQuery__
*
* To run a query within a React component, call `useGetApiKeyQuery` and pass it any options that fit your needs.
* When your component renders, `useGetApiKeyQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetApiKeyQuery({
* variables: {
* apiKeyId: // value for 'apiKeyId'
* },
* });
*/
export function useGetApiKeyQuery(baseOptions: Apollo.QueryHookOptions<GetApiKeyQuery, GetApiKeyQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetApiKeyQuery, GetApiKeyQueryVariables>(GetApiKeyDocument, options);
}
export function useGetApiKeyLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetApiKeyQuery, GetApiKeyQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetApiKeyQuery, GetApiKeyQueryVariables>(GetApiKeyDocument, options);
}
export type GetApiKeyQueryHookResult = ReturnType<typeof useGetApiKeyQuery>;
export type GetApiKeyLazyQueryHookResult = ReturnType<typeof useGetApiKeyLazyQuery>;
export type GetApiKeyQueryResult = Apollo.QueryResult<GetApiKeyQuery, GetApiKeyQueryVariables>;
export const DeleteUserAccountDocument = gql`
mutation DeleteUserAccount {
deleteUserAccount {

View File

@@ -24,7 +24,7 @@ export const CompanyBoard = ({
onEditColumnTitle,
}: CompanyBoardProps) => {
// TODO: we can store objectId and fieldDefinitions in the ViewBarContext
// And then use the useBoardViews hook wherever we need it in the board
// And then use the useBoardViews web-hook wherever we need it in the board
const { createView, deleteView, submitCurrentView, updateView } =
useBoardViews({
objectId: 'company',

View File

@@ -41,7 +41,7 @@ export const SettingsNavbar = () => {
end: false,
});
const isDevelopersSettingsActive = !!useMatch({
path: useResolvedPath('/settings/api').pathname,
path: useResolvedPath('/settings/developers/api-keys').pathname,
end: true,
});
@@ -104,7 +104,7 @@ export const SettingsNavbar = () => {
{isDevelopersSettingsEnabled && (
<NavItem
label="Developers"
to="/settings/apis"
to="/settings/developers/api-keys"
Icon={IconRobot}
active={isDevelopersSettingsActive}
/>

View File

@@ -0,0 +1,51 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCopy } from '@/ui/display/icon';
import { useSnackBar } from '@/ui/feedback/snack-bar/hooks/useSnackBar';
import { Button } from '@/ui/input/button/components/Button';
import { TextInput } from '@/ui/input/components/TextInput';
import { beautifyDateDiff } from '~/utils/date-utils';
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
`;
const StyledLinkContainer = styled.div`
flex: 1;
margin-right: ${({ theme }) => theme.spacing(2)};
`;
type ApiKeyInputProps = { expiresAt?: string | null; apiKey: string };
export const ApiKeyInput = ({ expiresAt, apiKey }: ApiKeyInputProps) => {
const theme = useTheme();
const computeInfo = () => {
if (!expiresAt) {
return '';
}
return `This key will expire in ${beautifyDateDiff(expiresAt)}`;
};
const { enqueueSnackBar } = useSnackBar();
return (
<StyledContainer>
<StyledLinkContainer>
<TextInput info={computeInfo()} value={apiKey} fullWidth />
</StyledLinkContainer>
<Button
Icon={IconCopy}
title="Copy"
onClick={() => {
enqueueSnackBar('Api Key copied to clipboard', {
variant: 'success',
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
});
navigator.clipboard.writeText(apiKey);
}}
/>
</StyledContainer>
);
};

View File

@@ -25,7 +25,7 @@ const StyledIconChevronRight = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
export const SettingsApisFieldItemTableRow = ({
export const SettingsApiKeysFieldItemTableRow = ({
fieldItem,
}: {
fieldItem: ApisFiedlItem;

View File

@@ -0,0 +1,19 @@
import { Meta, StoryObj } from '@storybook/react';
import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
const meta: Meta<typeof ApiKeyInput> = {
title: 'Pages/Settings/Developers/ApiKeys/ApiKeyInput',
component: ApiKeyInput,
decorators: [ComponentDecorator],
args: {
expiresAt: '2123-11-06T23:59:59.825Z',
apiKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0d2VudHktN2VkOWQyMTItMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNjk4MTQyODgyLCJleHAiOjE2OTk0MDE1OTksImp0aSI6ImMyMmFiNjQxLTVhOGYtNGQwMC1iMDkzLTk3MzUwYTM2YzZkOSJ9.JIe2TX5IXrdNl3n-kRFp3jyfNUE7unzXZLAzm2Gxl98',
},
};
export default meta;
type Story = StoryObj<typeof ApiKeyInput>;
export const Default: Story = {};

View File

@@ -0,0 +1,23 @@
import { Meta, StoryObj } from '@storybook/react';
import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/components/SettingsApiKeysFieldItemTableRow';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
const meta: Meta<typeof SettingsApiKeysFieldItemTableRow> = {
title: 'Pages/Settings/Developers/ApiKeys/SettingsApiKeysFieldItemTableRow',
component: SettingsApiKeysFieldItemTableRow,
decorators: [ComponentDecorator],
args: {
fieldItem: {
id: '3f4a42e8-b81f-4f8c-9c20-1602e6b34791',
name: 'Zapier Api Key',
type: 'internal',
expiration: 'In 3 days',
},
},
};
export default meta;
type Story = StoryObj<typeof SettingsApiKeysFieldItemTableRow>;
export const Default: Story = {};

View File

@@ -0,0 +1,11 @@
export const ExpirationDates: {
value: number;
label: string;
}[] = [
{ label: '15 days', value: 15 },
{ label: '30 days', value: 30 },
{ label: '90 days', value: 90 },
{ label: '1 year', value: 365 },
{ label: '2 years', value: 2 * 365 },
{ label: 'Never', value: 10 * 365 },
];

View File

@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const DELETE_ONE_API_KEY = gql`
mutation DeleteOneApiKey($apiKeyId: String!) {
revokeOneApiKey(where: { id: $apiKeyId }) {
id
}
}
`;

View File

@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const INSERT_ONE_API_KEY = gql`
mutation InsertOneApiKey($data: ApiKeyCreateInput!) {
createOneApiKey(data: $data) {
id
token
expiresAt
}
}
`;

View File

@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const GET_API_KEY = gql`
query GetApiKey($apiKeyId: String!) {
findManyApiKey(where: { id: { equals: $apiKeyId } }) {
id
name
expiresAt
}
}
`;

View File

@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const generatedApiKeyState = atom<string | null | undefined>({
key: 'generatedApiKeyState',
default: null,
});

View File

@@ -36,7 +36,7 @@ export const NameFields = ({
const [updateUser] = useUpdateUserMutation();
// TODO: Enhance this with react-hook-form (https://www.react-hook-form.com)
// TODO: Enhance this with react-web-hook-form (https://www.react-hook-form.com)
const debouncedUpdate = debounce(async () => {
if (onFirstNameUpdate) {
onFirstNameUpdate(firstName);

View File

@@ -34,7 +34,7 @@ export const NameField = ({
const [updateWorkspace] = useUpdateWorkspaceMutation();
// TODO: Enhance this with react-hook-form (https://www.react-hook-form.com)
// TODO: Enhance this with react-web-hook-form (https://www.react-hook-form.com)
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdate = useCallback(
debounce(async (name: string) => {

View File

@@ -20,6 +20,7 @@ export enum AppPath {
ObjectTablePage = '/objects/:objectNamePlural',
SettingsCatchAll = `/settings/*`,
DevelopersCatchAll = `/developers/*`,
// Impersonate
Impersonate = '/impersonate/:userId',

View File

@@ -9,5 +9,7 @@ export enum SettingsPath {
NewObject = 'objects/new',
WorkspaceMembersPage = 'workspace-members',
Workspace = 'workspace',
Apis = 'apis',
Developers = 'api-keys',
DevelopersNewApiKey = 'api-keys/new',
DevelopersApiKeyDetail = 'api-keys/:apiKeyId',
}

View File

@@ -5,7 +5,7 @@ import { FieldMetadata } from '../types/FieldMetadata';
export type GenericFieldContextType = {
fieldDefinition: FieldDefinition<FieldMetadata>;
// TODO: add better typing for mutation hook
// TODO: add better typing for mutation web-hook
useUpdateEntityMutation: () => [(params: any) => void, any];
entityId: string;
recoilScopeId: string;

View File

@@ -11,7 +11,7 @@ import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { SelectHotkeyScope } from '../types/SelectHotkeyScope';
export type SelectProps<Value extends string> = {
export type SelectProps<Value extends string | number> = {
dropdownScopeId: string;
onChange: (value: Value) => void;
options: { value: Value; label: string; Icon?: IconComponent }[];
@@ -38,7 +38,7 @@ const StyledLabel = styled.div`
gap: ${({ theme }) => theme.spacing(1)};
`;
export const Select = <Value extends string>({
export const Select = <Value extends string | number>({
dropdownScopeId,
onChange,
options,

View File

@@ -25,6 +25,7 @@ export type TextInputComponentProps = Omit<
> & {
className?: string;
label?: string;
info?: string;
onChange?: (text: string) => void;
fullWidth?: boolean;
disableHotkeys?: boolean;
@@ -45,10 +46,16 @@ const StyledLabel = styled.span`
text-transform: uppercase;
`;
const StyledInfo = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-top: ${({ theme }) => theme.spacing(1)};
`;
const StyledInputContainer = styled.div`
display: flex;
flex-direction: row;
width: 100%;
`;
@@ -113,6 +120,7 @@ const TextInputComponent = (
{
className,
label,
info,
value,
onChange,
onFocus,
@@ -204,6 +212,7 @@ const TextInputComponent = (
)}
</StyledTrailingIconContainer>
</StyledInputContainer>
{info && <StyledInfo>{info}</StyledInfo>}
{error && <StyledErrorHelper>{error}</StyledErrorHelper>}
</StyledContainer>
);

View File

@@ -6,11 +6,11 @@ import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { Select, SelectProps } from '../Select';
type RenderProps = SelectProps<string>;
type RenderProps = SelectProps<string | number>;
const Render = (args: RenderProps) => {
const [value, setValue] = useState(args.value);
const handleChange = (value: string) => {
const handleChange = (value: string | number) => {
args.onChange?.(value);
setValue(value);
};

View File

@@ -38,3 +38,7 @@ export const Filled: Story = {
export const Disabled: Story = {
args: { disabled: true, value: 'Tim' },
};
export const WithInfo: Story = {
args: { info: 'Some info displayed below the input', value: 'Tim' },
};

View File

@@ -4,7 +4,7 @@ import { companyProgressesFamilyState } from '@/companies/states/companyProgress
import { boardCardIdsByColumnIdFamilyState } from '../boardCardIdsByColumnIdFamilyState';
// TODO: this state should be computed during the synchronization hook and put in a generic
// TODO: this state should be computed during the synchronization web-hook and put in a generic
// boardColumnTotalsFamilyState indexed by columnId.
export const boardColumnTotalsFamilySelector = selectorFamily({
key: 'boardColumnTotalsFamilySelector',

View File

@@ -22,7 +22,7 @@ const TestComponentDomMode = () => {
);
};
test('useListenClickOutside hook works in dom mode', async () => {
test('useListenClickOutside web-hook works in dom mode', async () => {
const { getByText } = render(<TestComponentDomMode />);
const inside = getByText('Inside');
const inside2 = getByText('Inside 2');

View File

@@ -136,7 +136,7 @@ export const CreateProfile = () => {
title="Name"
description="Your name as it will be displayed on the app"
/>
{/* TODO: When react-hook-form is added to edit page we should create a dedicated component with context */}
{/* TODO: When react-web-hook-form is added to edit page we should create a dedicated component with context */}
<StyledComboInputContainer>
<Controller
name="firstName"

View File

@@ -1,25 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { SettingsApis } from '../SettingsApis';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/SettingsApi',
component: SettingsApis,
decorators: [PageDecorator],
args: { routePath: '/settings/apis' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsApis>;
export const Default: Story = {};

View File

@@ -0,0 +1,76 @@
import { useNavigate, useParams } from 'react-router-dom';
import { useRecoilState } from 'recoil';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput';
import { generatedApiKeyState } from '@/settings/developers/states/generatedApiKeyState';
import { IconSettings, IconTrash } from '@/ui/display/icon';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Button } from '@/ui/input/button/components/Button';
import { TextInput } from '@/ui/input/components/TextInput';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import {
useDeleteOneApiKeyMutation,
useGetApiKeyQuery,
} from '~/generated/graphql';
export const SettingsDevelopersApiKeyDetail = () => {
const navigate = useNavigate();
const { apiKeyId = '' } = useParams();
const [generatedApiKey] = useRecoilState(generatedApiKeyState);
const apiKeyQuery = useGetApiKeyQuery({
variables: {
apiKeyId,
},
});
const [deleteApiKey] = useDeleteOneApiKeyMutation();
const deleteIntegration = async () => {
await deleteApiKey({ variables: { apiKeyId } });
navigate('/settings/developers/api-keys');
};
const { expiresAt, name } = apiKeyQuery.data?.findManyApiKey[0] || {};
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'APIs', href: '/settings/developers/api-keys' },
{ children: name || '' },
]}
/>
</SettingsHeaderContainer>
<Section>
<H2Title
title="Api Key"
description="Copy this key as it will only be visible this one time"
/>
<ApiKeyInput expiresAt={expiresAt} apiKey={generatedApiKey || ''} />
</Section>
<Section>
<H2Title title="Name" description="Name of your API key" />
<TextInput
placeholder="E.g. backoffice integration"
value={name || ''}
disabled={true}
fullWidth
/>
</Section>
<Section>
<H2Title title="Danger zone" description="Delete this integration" />
<Button
accent="danger"
variant="secondary"
title="Disable"
Icon={IconTrash}
onClick={deleteIntegration}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@@ -2,7 +2,7 @@ import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled';
import { objectSettingsWidth } from '@/settings/data-model/constants/objectSettings';
import { SettingsApisFieldItemTableRow } from '@/settings/developers/components/SettingsApisFieldItemTableRow';
import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/components/SettingsApiKeysFieldItemTableRow';
import { activeApiKeyItems } from '@/settings/developers/constants/mockObjects';
import { IconPlus, IconSettings } from '@/ui/display/icon';
import { H1Title } from '@/ui/display/typography/components/H1Title';
@@ -34,7 +34,7 @@ const StyledH1Title = styled(H1Title)`
margin-bottom: 0;
`;
export const SettingsApis = () => {
export const SettingsDevelopersApiKeys = () => {
const navigate = useNavigate();
return (
@@ -48,7 +48,7 @@ export const SettingsApis = () => {
accent="blue"
size="small"
onClick={() => {
navigate('/');
navigate('/settings/developers/api-keys/new');
}}
/>
</StyledHeader>
@@ -64,7 +64,7 @@ export const SettingsApis = () => {
<TableHeader></TableHeader>
</StyledTableRow>
{activeApiKeyItems.map((fieldItem) => (
<SettingsApisFieldItemTableRow
<SettingsApiKeysFieldItemTableRow
key={fieldItem.id}
fieldItem={fieldItem}
/>

View File

@@ -0,0 +1,100 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { DateTime } from 'luxon';
import { useRecoilState } from 'recoil';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ExpirationDates } from '@/settings/developers/constants/expirationDates';
import { generatedApiKeyState } from '@/settings/developers/states/generatedApiKeyState';
import { IconSettings } from '@/ui/display/icon';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useInsertOneApiKeyMutation } from '~/generated/graphql';
export const SettingsDevelopersApiKeysNew = () => {
const [insertOneApiKey] = useInsertOneApiKeyMutation();
const navigate = useNavigate();
const [, setGeneratedApiKey] = useRecoilState(generatedApiKeyState);
const [formValues, setFormValues] = useState<{
name: string;
expirationDate: number;
}>({
expirationDate: ExpirationDates[0].value,
name: '',
});
const onSave = async () => {
const apiKey = await insertOneApiKey({
variables: {
data: {
name: formValues.name,
expiresAt: DateTime.now()
.plus({ days: formValues.expirationDate })
.toISODate(),
},
},
});
setGeneratedApiKey(apiKey.data?.createOneApiKey?.token);
navigate(
`/settings/developers/api-keys/${apiKey.data?.createOneApiKey?.id}`,
);
};
const canSave = !!formValues.name;
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'APIs', href: '/settings/developers/api-keys' },
{ children: 'New' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => {
navigate('/settings/developers/api-keys');
}}
onSave={onSave}
/>
</SettingsHeaderContainer>
<Section>
<H2Title title="Name" description="Name of your API key" />
<TextInput
placeholder="E.g. backoffice integration"
value={formValues.name}
onChange={(value) => {
setFormValues((prevState) => ({
...prevState,
name: value,
}));
}}
fullWidth
/>
</Section>
<Section>
<H2Title
title="Expiration Date"
description="When the API key will expire."
/>
<Select
dropdownScopeId="object-field-type-select"
options={ExpirationDates}
value={formValues.expirationDate}
onChange={(value) => {
setFormValues((prevState) => ({
...prevState,
expirationDate: value,
}));
}}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@@ -0,0 +1,29 @@
import { Meta, StoryObj } from '@storybook/react';
import { SettingsDevelopersApiKeys } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeys';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/testing/sleep';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Developers/ApiKeys/SettingsDevelopersApiKeys',
component: SettingsDevelopersApiKeys,
decorators: [PageDecorator],
args: { routePath: '/settings/developers/api-keys' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsDevelopersApiKeys>;
export const Default: Story = {
play: async ({}) => {
await sleep(100);
},
};

View File

@@ -0,0 +1,32 @@
import { Meta, StoryObj } from '@storybook/react';
import { SettingsDevelopersApiKeyDetail } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedApiKeyToken } from '~/testing/mock-data/api-keys';
import { sleep } from '~/testing/sleep';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Developers/ApiKeys/SettingsDevelopersApiKeyDetail',
component: SettingsDevelopersApiKeyDetail,
decorators: [PageDecorator],
args: {
routePath: '/settings/apis/f7c6d736-8fcd-4e9c-ab99-28f6a9031570',
state: mockedApiKeyToken,
},
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsDevelopersApiKeyDetail>;
export const Default: Story = {
play: async ({}) => {
await sleep(100);
},
};

View File

@@ -0,0 +1,29 @@
import { Meta, StoryObj } from '@storybook/react';
import { SettingsDevelopersApiKeysNew } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/testing/sleep';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Developers/ApiKeys/SettingsDevelopersApiKeysNew',
component: SettingsDevelopersApiKeysNew,
decorators: [PageDecorator],
args: { routePath: '/settings/developers/api-keys/new' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsDevelopersApiKeysNew>;
export const Default: Story = {
play: async ({}) => {
await sleep(100);
},
};

View File

@@ -8,23 +8,41 @@ import { UserProvider } from '~/modules/users/components/UserProvider';
import { FullHeightStorybookLayout } from '../FullHeightStorybookLayout';
export type PageDecoratorArgs = { routePath: string; routeParams: RouteParams };
export type PageDecoratorArgs = {
routePath: string;
routeParams: RouteParams;
state?: string;
};
type RouteParams = {
[param: string]: string;
};
const computeLocation = (routePath: string, routeParams: RouteParams) =>
routePath.replace(/:(\w+)/g, (paramName) => routeParams[paramName] ?? '');
const computeLocation = (
routePath: string,
routeParams: RouteParams,
state?: string,
) => {
return {
pathname: routePath.replace(
/:(\w+)/g,
(paramName) => routeParams[paramName] ?? '',
),
state,
};
};
export const PageDecorator: Decorator<{
routePath: string;
routeParams: RouteParams;
state?: string;
}> = (Story, { args }) => (
<UserProvider>
<ClientConfigProvider>
<MemoryRouter
initialEntries={[computeLocation(args.routePath, args.routeParams)]}
initialEntries={[
computeLocation(args.routePath, args.routeParams, args.state),
]}
>
<FullHeightStorybookLayout>
<HelmetProvider>

View File

@@ -17,6 +17,7 @@ import { SEARCH_ACTIVITY_QUERY } from '@/search/graphql/queries/searchActivityQu
import { SEARCH_COMPANY_QUERY } from '@/search/graphql/queries/searchCompanyQuery';
import { SEARCH_PEOPLE_QUERY } from '@/search/graphql/queries/searchPeopleQuery';
import { SEARCH_USER_QUERY } from '@/search/graphql/queries/searchUserQuery';
import { GET_API_KEY } from '@/settings/developers/graphql/queries/getApiKey';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import { GET_VIEW_FIELDS } from '@/views/graphql/queries/getViewFields';
import { GET_VIEWS } from '@/views/graphql/queries/getViews';
@@ -30,6 +31,7 @@ import {
SearchUserQuery,
ViewType,
} from '~/generated/graphql';
import { mockedApiKeys } from '~/testing/mock-data/api-keys';
import { mockedActivities, mockedTasks } from './mock-data/activities';
import {
@@ -278,6 +280,13 @@ export const graphqlMocks = [
}),
);
}),
graphql.query(getOperationName(GET_API_KEY) ?? '', (req, res, ctx) => {
return res(
ctx.data({
findManyApiKey: mockedApiKeys[0],
}),
);
}),
graphql.mutation(
getOperationName(CREATE_ACTIVITY_WITH_COMMENT) ?? '',
(req, res, ctx) => {

View File

@@ -0,0 +1,18 @@
import { ApiKey } from '~/generated/graphql';
type MockedApiKey = Pick<
ApiKey,
'id' | 'name' | 'createdAt' | 'updatedAt' | 'expiresAt' | '__typename'
>;
export const mockedApiKeyToken =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0d2VudHktN2VkOWQyMTItMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNjk4MDkzMDU0LCJleHAiOjE2OTkzMTUxOTksImp0aSI6IjY0Njg3ZWNmLWFhYzktNDNmYi1hY2I4LTE1M2QzNzgwYmIzMSJ9.JkQ3u7aRiqOFQkgHcC-mgCU37096HRSo40A_9X8gEng';
export const mockedApiKeys: Array<MockedApiKey> = [
{
id: 'f7c6d736-8fcd-4e9c-ab99-28f6a9031570',
name: 'Zapier Integration',
createdAt: '2023-04-26T10:12:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00',
expiresAt: '2100-11-06T23:59:59.825Z',
__typename: 'ApiKey',
},
];

View File

@@ -2,6 +2,7 @@ import { formatDistanceToNow } from 'date-fns';
import { DateTime } from 'luxon';
import {
beautifyDateDiff,
beautifyExactDate,
beautifyExactDateTime,
beautifyPastDateAbsolute,
@@ -237,3 +238,47 @@ describe('hasDatePassed', () => {
expect(result).toEqual(false);
});
});
describe('beautifyDateDiff', () => {
it('should return the correct date diff', () => {
const date = '2023-11-05T00:00:00.000Z';
const dateToCompareWith = '2023-11-01T00:00:00.000Z';
const result = beautifyDateDiff(date, dateToCompareWith);
expect(result).toEqual('4 days');
});
it('should return the correct date diff for large diff', () => {
const date = '2033-11-05T00:00:00.000Z';
const dateToCompareWith = '2023-11-01T00:00:00.000Z';
const result = beautifyDateDiff(date, dateToCompareWith);
expect(result).toEqual('10 years and 4 days');
});
it('should return the correct date for negative diff', () => {
const date = '2013-11-05T00:00:00.000Z';
const dateToCompareWith = '2023-11-01T00:00:00.000Z';
const result = beautifyDateDiff(date, dateToCompareWith);
expect(result).toEqual('-9 years and -361 days');
});
it('should return the correct date diff for large diff', () => {
const date = '2033-11-01T00:00:00.000Z';
const dateToCompareWith = '2023-11-01T00:00:00.000Z';
const result = beautifyDateDiff(date, dateToCompareWith);
expect(result).toEqual('10 years');
});
it('should return the proper english date diff', () => {
const date = '2024-11-02T00:00:00.000Z';
const dateToCompareWith = '2023-11-01T00:00:00.000Z';
const result = beautifyDateDiff(date, dateToCompareWith);
expect(result).toEqual('1 year and 1 day');
});
it('should round date diff', () => {
const date = '2024-11-03T14:04:43.421Z';
const dateToCompareWith = '2023-11-01T00:00:00.000Z';
const result = beautifyDateDiff(date, dateToCompareWith);
expect(result).toEqual('1 year and 2 days');
});
it('should compare to now', () => {
const date = '2200-11-01T00:00:00.000Z';
const result = beautifyDateDiff(date);
expect(result).toContain('years');
});
});

View File

@@ -108,3 +108,17 @@ export const hasDatePassed = (date: Date | string | number) => {
return false;
}
};
export const beautifyDateDiff = (date: string, dateToCompareWith?: string) => {
const dateDiff = DateTime.fromISO(date).diff(
dateToCompareWith ? DateTime.fromISO(dateToCompareWith) : DateTime.now(),
['years', 'days'],
);
let result = '';
if (dateDiff.years) result = result + `${dateDiff.years} year`;
if (![0, 1].includes(dateDiff.years)) result = result + 's';
if (dateDiff.years && dateDiff.days) result = result + ' and ';
if (dateDiff.days) result = result + `${Math.floor(dateDiff.days)} day`;
if (![0, 1].includes(dateDiff.days)) result = result + 's';
return result;
};

View File

@@ -20,7 +20,7 @@ export default {
label: 'Api Key',
type: 'string',
helpText:
'Create the api key in [your twenty workspace](https://app.twenty.com/settings/apis)',
'Create the api-keys key in [your twenty workspace](https://app.twenty.com/settings/developers/api-keys)',
},
],
connectionLabel: '{{data.currentWorkspace.displayName}}',

View File

@@ -19,12 +19,12 @@ describe('triggers.company', () => {
requestDb(
z,
bundle,
`query findManyHook {findManyHook(where: {id: {equals: "${result.id}"}}){id operation}}`,
`query findManyWebHook {findManyWebHook(where: {id: {equals: "${result.id}"}}){id operation}}`,
),
bundle,
);
expect(checkDbResult.data.findManyHook.length).toEqual(1);
expect(checkDbResult.data.findManyHook[0].operation).toEqual(
expect(checkDbResult.data.findManyWebHook.length).toEqual(1);
expect(checkDbResult.data.findManyWebHook[0].operation).toEqual(
'createOneCompany',
);
});
@@ -48,13 +48,13 @@ describe('triggers.company', () => {
requestDb(
z,
bundle,
`query findManyHook {findManyHook(where: {id: {equals: "${result.id}"}}){id}}`,
`query findManyWebHook {findManyWebHook(where: {id: {equals: "${result.id}"}}){id}}`,
),
bundle,
);
expect(checkDbResult.data.findManyHook.length).toEqual(0);
expect(checkDbResult.data.findManyWebHook.length).toEqual(0);
});
test('should load company from hook', async () => {
test('should load company from web-hook', async () => {
const bundle = {
cleanedRequest: {
id: 'd6ccb1d1-a90b-4822-a992-a0dd946592c9',

View File

@@ -7,22 +7,22 @@ const performSubscribe = async (z: ZObject, bundle: Bundle) => {
const result = await requestDb(
z,
bundle,
`mutation createOneHook {createOneHook(data:{${handleQueryParams(
`mutation createOneWebHook {createOneWebHook(data:{${handleQueryParams(
data,
)}}) {id}}`,
);
return result.data.createOneHook;
return result.data.createOneWebHook;
};
const performUnsubscribe = async (z: ZObject, bundle: Bundle) => {
const data = { id: bundle.subscribeData?.id };
const result = await requestDb(
z,
bundle,
`mutation deleteOneHook {deleteOneHook(where:{${handleQueryParams(
`mutation deleteOneWebHook {deleteOneWebHook(where:{${handleQueryParams(
data,
)}}) {id}}`,
);
return result.data.deleteOneHook;
return result.data.deleteOneWebHook;
};
const perform = (z: ZObject, bundle: Bundle) => {
return [bundle.cleanedRequest];
@@ -55,7 +55,7 @@ export default {
},
operation: {
inputFields: [],
type: 'hook',
type: 'web-hook',
performSubscribe,
performUnsubscribe,
perform,

View File

@@ -7,7 +7,6 @@ import {
ActivityTarget,
Attachment,
ApiKey,
Hook,
Comment,
Company,
Favorite,
@@ -22,6 +21,7 @@ import {
ViewField,
ViewFilter,
ViewSort,
WebHook,
Workspace,
WorkspaceMember,
} from '@prisma/client';
@@ -36,7 +36,7 @@ type SubjectsAbility = Subjects<{
Comment: Comment;
Company: Company;
Favorite: Favorite;
Hook: Hook;
WebHook: WebHook;
Person: Person;
Pipeline: Pipeline;
PipelineProgress: PipelineProgress;
@@ -83,10 +83,10 @@ export class AbilityFactory {
can(AbilityAction.Create, 'ApiKey');
can(AbilityAction.Update, 'ApiKey', { workspaceId: workspace.id });
// Hook
can(AbilityAction.Read, 'Hook', { workspaceId: workspace.id });
can(AbilityAction.Create, 'Hook');
can(AbilityAction.Delete, 'Hook', { workspaceId: workspace.id });
// WebHook
can(AbilityAction.Read, 'WebHook', { workspaceId: workspace.id });
can(AbilityAction.Create, 'WebHook');
can(AbilityAction.Delete, 'WebHook', { workspaceId: workspace.id });
// Workspace
can(AbilityAction.Read, 'Workspace');

View File

@@ -2,6 +2,11 @@ import { Module } from '@nestjs/common';
import { AbilityFactory } from 'src/ability/ability.factory';
import { PrismaService } from 'src/database/prisma.service';
import {
CreateWebHookAbilityHandler,
DeleteWebHookAbilityHandler,
ReadWebHookAbilityHandler,
} from 'src/ability/handlers/web-hook.ability-handler';
import {
CreateUserAbilityHandler,
@@ -129,11 +134,6 @@ import {
ManageApiKeyAbilityHandler,
ReadApiKeyAbilityHandler,
} from './handlers/api-key.ability-handler';
import {
CreateHookAbilityHandler,
DeleteHookAbilityHandler,
ReadHookAbilityHandler,
} from './handlers/hook.ability-handler';
@Module({
providers: [
@@ -247,9 +247,9 @@ import {
CreateApiKeyAbilityHandler,
UpdateApiKeyAbilityHandler,
// Hook
CreateHookAbilityHandler,
DeleteHookAbilityHandler,
ReadHookAbilityHandler,
CreateWebHookAbilityHandler,
DeleteWebHookAbilityHandler,
ReadWebHookAbilityHandler,
],
exports: [
AbilityFactory,
@@ -360,9 +360,9 @@ import {
CreateApiKeyAbilityHandler,
UpdateApiKeyAbilityHandler,
// Hook
CreateHookAbilityHandler,
DeleteHookAbilityHandler,
ReadHookAbilityHandler,
CreateWebHookAbilityHandler,
DeleteWebHookAbilityHandler,
ReadWebHookAbilityHandler,
],
})
export class AbilityModule {}

View File

@@ -16,14 +16,14 @@ import { AbilityAction } from 'src/ability/ability.action';
import { assert } from 'src/utils/assert';
@Injectable()
export class CreateHookAbilityHandler implements IAbilityHandler {
export class CreateWebHookAbilityHandler implements IAbilityHandler {
constructor(private readonly prismaService: PrismaService) {}
async handle(ability: AppAbility, context: ExecutionContext) {
const gqlContext = GqlExecutionContext.create(context);
const args = gqlContext.getArgs();
const allowed = await relationAbilityChecker(
'Hook',
'WebHook',
ability,
this.prismaService.client,
args,
@@ -31,27 +31,27 @@ export class CreateHookAbilityHandler implements IAbilityHandler {
if (!allowed) {
return false;
}
return ability.can(AbilityAction.Create, 'Hook');
return ability.can(AbilityAction.Create, 'WebHook');
}
}
@Injectable()
export class DeleteHookAbilityHandler implements IAbilityHandler {
export class DeleteWebHookAbilityHandler implements IAbilityHandler {
constructor(private readonly prismaService: PrismaService) {}
async handle(ability: AppAbility, context: ExecutionContext) {
const gqlContext = GqlExecutionContext.create(context);
const args = gqlContext.getArgs();
const hook = await this.prismaService.client.hook.findFirst({
const hook = await this.prismaService.client.webHook.findFirst({
where: args.where,
});
assert(hook, '', NotFoundException);
return ability.can(AbilityAction.Delete, subject('Hook', hook));
return ability.can(AbilityAction.Delete, subject('WebHook', hook));
}
}
@Injectable()
export class ReadHookAbilityHandler implements IAbilityHandler {
export class ReadWebHookAbilityHandler implements IAbilityHandler {
async handle(ability: AppAbility) {
return ability.can(AbilityAction.Read, 'Hook');
return ability.can(AbilityAction.Read, 'WebHook');
}
}

View File

@@ -19,7 +19,7 @@ import {
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { UserAbility } from 'src/decorators/user-ability.decorator';
import { AppAbility } from 'src/ability/ability.factory';
import { AuthToken } from 'src/core/auth/dto/token.entity';
import { ApiKeyToken } from 'src/core/auth/dto/token.entity';
import { ApiKeyService } from './api-key.service';
@@ -28,13 +28,13 @@ import { ApiKeyService } from './api-key.service';
export class ApiKeyResolver {
constructor(private readonly apiKeyService: ApiKeyService) {}
@Mutation(() => AuthToken)
@Mutation(() => ApiKeyToken)
@UseGuards(AbilityGuard)
@CheckAbilities(CreateApiKeyAbilityHandler)
async createOneApiKey(
@Args() args: CreateOneApiKeyArgs,
@AuthWorkspace() { id: workspaceId }: Workspace,
): Promise<AuthToken> {
): Promise<ApiKeyToken> {
return await this.apiKeyService.generateApiKeyToken(
workspaceId,
args.data.name,

View File

@@ -5,7 +5,7 @@ import { addMilliseconds, addSeconds } from 'date-fns';
import ms from 'ms';
import { PrismaService } from 'src/database/prisma.service';
import { AuthToken } from 'src/core/auth/dto/token.entity';
import { ApiKeyToken } from 'src/core/auth/dto/token.entity';
import { assert } from 'src/utils/assert';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
@@ -28,7 +28,7 @@ export class ApiKeyService {
workspaceId: string,
name: string,
expiresAt?: Date | string,
): Promise<AuthToken> {
): Promise<ApiKeyToken> {
const secret = this.environmentService.getAccessTokenSecret();
let expiresIn: string | number;
let expirationDate: Date;
@@ -52,6 +52,7 @@ export class ApiKeyService {
},
});
return {
id,
token: this.jwtService.sign(jwtPayload, {
secret,
expiresIn,

View File

@@ -9,6 +9,18 @@ export class AuthToken {
expiresAt: Date;
}
@ObjectType()
export class ApiKeyToken {
@Field(() => String)
id: string;
@Field(() => String)
token: string;
@Field(() => Date)
expiresAt: Date;
}
@ObjectType()
export class AuthTokenPair {
@Field(() => AuthToken)

View File

@@ -1,5 +1,7 @@
import { Module } from '@nestjs/common';
import { WebHookModule } from 'src/core/web-hook/web-hook.module';
import { UserModule } from './user/user.module';
import { CommentModule } from './comment/comment.module';
import { CompanyModule } from './company/company.module';
@@ -15,7 +17,6 @@ import { ActivityModule } from './activity/activity.module';
import { ViewModule } from './view/view.module';
import { FavoriteModule } from './favorite/favorite.module';
import { ApiKeyModule } from './api-key/api-key.module';
import { HookModule } from './hook/hook.module';
@Module({
imports: [
@@ -34,7 +35,7 @@ import { HookModule } from './hook/hook.module';
ViewModule,
FavoriteModule,
ApiKeyModule,
HookModule,
WebHookModule,
],
exports: [
AuthModule,
@@ -48,7 +49,7 @@ import { HookModule } from './hook/hook.module';
AttachmentModule,
FavoriteModule,
ApiKeyModule,
HookModule,
WebHookModule,
],
})
export class CoreModule {}

View File

@@ -2,11 +2,10 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/database/prisma.module';
import { AbilityModule } from 'src/ability/ability.module';
import { HookResolver } from './hook.resolver';
import { WebHookResolver } from 'src/core/web-hook/web-hook.resolver';
@Module({
imports: [PrismaModule, AbilityModule],
providers: [HookResolver],
providers: [WebHookResolver],
})
export class HookModule {}
export class WebHookModule {}

View File

@@ -4,35 +4,35 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { accessibleBy } from '@casl/prisma';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { Hook } from 'src/core/@generated/hook/hook.model';
import { AbilityGuard } from 'src/guards/ability.guard';
import { CheckAbilities } from 'src/decorators/check-abilities.decorator';
import {
CreateHookAbilityHandler,
DeleteHookAbilityHandler,
ReadHookAbilityHandler,
} from 'src/ability/handlers/hook.ability-handler';
import { CreateOneHookArgs } from 'src/core/@generated/hook/create-one-hook.args';
CreateWebHookAbilityHandler,
DeleteWebHookAbilityHandler,
ReadWebHookAbilityHandler,
} from 'src/ability/handlers/web-hook.ability-handler';
import { PrismaService } from 'src/database/prisma.service';
import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
import { Workspace } from 'src/core/@generated/workspace/workspace.model';
import { DeleteOneHookArgs } from 'src/core/@generated/hook/delete-one-hook.args';
import { FindManyHookArgs } from 'src/core/@generated/hook/find-many-hook.args';
import { UserAbility } from 'src/decorators/user-ability.decorator';
import { AppAbility } from 'src/ability/ability.factory';
import { CreateOneWebHookArgs } from 'src/core/@generated/web-hook/create-one-web-hook.args';
import { DeleteOneWebHookArgs } from 'src/core/@generated/web-hook/delete-one-web-hook.args';
import { FindManyWebHookArgs } from 'src/core/@generated/web-hook/find-many-web-hook.args';
import { WebHook } from 'src/core/@generated/web-hook/web-hook.model';
@UseGuards(JwtAuthGuard)
@Resolver(() => Hook)
export class HookResolver {
@Resolver(() => WebHook)
export class WebHookResolver {
constructor(private readonly prismaService: PrismaService) {}
@Mutation(() => Hook)
@Mutation(() => WebHook)
@UseGuards(AbilityGuard)
@CheckAbilities(CreateHookAbilityHandler)
async createOneHook(
@Args() args: CreateOneHookArgs,
@CheckAbilities(CreateWebHookAbilityHandler)
async createOneWebHook(
@Args() args: CreateOneWebHookArgs,
@AuthWorkspace() { id: workspaceId }: Workspace,
): Promise<Hook> {
return this.prismaService.client.hook.create({
): Promise<WebHook> {
return this.prismaService.client.webHook.create({
data: {
...args.data,
...{ workspace: { connect: { id: workspaceId } } },
@@ -40,31 +40,31 @@ export class HookResolver {
});
}
@Mutation(() => Hook, { nullable: false })
@Mutation(() => WebHook, { nullable: false })
@UseGuards(AbilityGuard)
@CheckAbilities(DeleteHookAbilityHandler)
async deleteOneHook(@Args() args: DeleteOneHookArgs): Promise<Hook> {
const hookToDelete = this.prismaService.client.hook.findUnique({
@CheckAbilities(DeleteWebHookAbilityHandler)
async deleteOneWebHook(@Args() args: DeleteOneWebHookArgs): Promise<WebHook> {
const hookToDelete = this.prismaService.client.webHook.findUnique({
where: args.where,
});
if (!hookToDelete) {
throw new NotFoundException();
}
return await this.prismaService.client.hook.delete({
return await this.prismaService.client.webHook.delete({
where: args.where,
});
}
@Query(() => [Hook])
@Query(() => [WebHook])
@UseGuards(AbilityGuard)
@CheckAbilities(ReadHookAbilityHandler)
async findManyHook(
@Args() args: FindManyHookArgs,
@CheckAbilities(ReadWebHookAbilityHandler)
async findManyWebHook(
@Args() args: FindManyWebHookArgs,
@UserAbility() ability: AppAbility,
) {
const filterOptions = [accessibleBy(ability).WorkspaceMember];
if (args.where) filterOptions.push(args.where);
return this.prismaService.client.hook.findMany({
return this.prismaService.client.webHook.findMany({
...args,
where: { AND: filterOptions },
});

View File

@@ -0,0 +1,27 @@
/*
Warnings:
- You are about to drop the `hooks` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "hooks" DROP CONSTRAINT "hooks_workspaceId_fkey";
-- DropTable
DROP TABLE "hooks";
-- CreateTable
CREATE TABLE "web_hooks" (
"id" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"targetUrl" TEXT NOT NULL,
"operation" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "web_hooks_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "web_hooks" ADD CONSTRAINT "web_hooks_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -179,7 +179,7 @@ model Workspace {
views View[]
viewSorts ViewSort[]
apiKeys ApiKey[]
hooks Hook[]
webHooks WebHook[]
/// @TypeGraphQL.omit(input: true, output: true)
deletedAt DateTime?
@@ -910,7 +910,7 @@ model ApiKey {
@@map("api_keys")
}
model Hook {
model WebHook {
/// @Validator.IsString()
/// @Validator.IsOptional()
id String @id @default(uuid())
@@ -925,5 +925,5 @@ model Hook {
/// @TypeGraphQL.omit(input: true, output: true)
deletedAt DateTime?
@@map("hooks")
@@map("web_hooks")
}

View File

@@ -22,5 +22,5 @@ export type ModelSelectMap = {
ViewSort: Prisma.ViewSortSelect;
ViewField: Prisma.ViewFieldSelect;
ApiKey: Prisma.ApiKeySelect;
Hook: Prisma.HookSelect;
WebHook: Prisma.WebHookSelect;
};