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 ```bash
cp .env.example .env 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. 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`', 'message': 'Icon imports are only allowed for `@/ui/icon`',
}, },
{ {
'group': ['react-hotkeys-hook'], 'group': ['react-hotkeys-web-hook'],
"importNames": ["useHotkeys"], "importNames": ["useHotkeys"],
'message': 'Please use the custom wrapper: `useScopedHotkeys`', '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 { SettingsObjectDetail } from '~/pages/settings/data-model/SettingsObjectDetail';
import { SettingsObjectEdit } from '~/pages/settings/data-model/SettingsObjectEdit'; import { SettingsObjectEdit } from '~/pages/settings/data-model/SettingsObjectEdit';
import { SettingsObjects } from '~/pages/settings/data-model/SettingsObjects'; 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 { SettingsExperience } from '~/pages/settings/SettingsExperience';
import { SettingsProfile } from '~/pages/settings/SettingsProfile'; import { SettingsProfile } from '~/pages/settings/SettingsProfile';
import { SettingsWorkspace } from '~/pages/settings/SettingsWorkspace'; import { SettingsWorkspace } from '~/pages/settings/SettingsWorkspace';
@@ -31,7 +34,6 @@ import { getPageTitleFromPath } from '~/utils/title-utils';
import { ObjectTablePage } from './modules/metadata/components/ObjectTablePage'; import { ObjectTablePage } from './modules/metadata/components/ObjectTablePage';
import { SettingsObjectNewFieldStep1 } from './pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep1'; import { SettingsObjectNewFieldStep1 } from './pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep1';
import { SettingsObjectNewFieldStep2 } from './pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2'; import { SettingsObjectNewFieldStep2 } from './pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2';
import { SettingsApis } from './pages/settings/SettingsApis';
export const App = () => { export const App = () => {
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -97,7 +99,25 @@ export const App = () => {
path={SettingsPath.NewObject} path={SettingsPath.NewObject}
element={<SettingsNewObject />} 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 <Route
path={SettingsPath.ObjectNewFieldStep1} path={SettingsPath.ObjectNewFieldStep1}
element={<SettingsObjectNewFieldStep1 />} element={<SettingsObjectNewFieldStep1 />}

View File

@@ -760,6 +760,15 @@ export enum ViewType {
Table = 'Table' 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 = { export type Workspace = {
__typename?: 'Workspace'; __typename?: 'Workspace';
Attachment?: Maybe<Array<Attachment>>; Attachment?: Maybe<Array<Attachment>>;
@@ -783,6 +792,7 @@ export type Workspace = {
viewFilters?: Maybe<Array<ViewFilter>>; viewFilters?: Maybe<Array<ViewFilter>>;
viewSorts?: Maybe<Array<ViewSort>>; viewSorts?: Maybe<Array<ViewSort>>;
views?: Maybe<Array<View>>; views?: Maybe<Array<View>>;
webHooks?: Maybe<Array<WebHook>>;
workspaceMember?: Maybe<Array<WorkspaceMember>>; workspaceMember?: Maybe<Array<WorkspaceMember>>;
}; };

View File

@@ -482,6 +482,13 @@ export enum ApiKeyScalarFieldEnum {
WorkspaceId = 'workspaceId' WorkspaceId = 'workspaceId'
} }
export type ApiKeyToken = {
__typename?: 'ApiKeyToken';
expiresAt: Scalars['DateTime'];
id: Scalars['String'];
token: Scalars['String'];
};
export type ApiKeyUpdateManyWithoutWorkspaceNestedInput = { export type ApiKeyUpdateManyWithoutWorkspaceNestedInput = {
connect?: InputMaybe<Array<ApiKeyWhereUniqueInput>>; connect?: InputMaybe<Array<ApiKeyWhereUniqueInput>>;
disconnect?: InputMaybe<Array<ApiKeyWhereUniqueInput>>; disconnect?: InputMaybe<Array<ApiKeyWhereUniqueInput>>;
@@ -1392,7 +1399,7 @@ export type Mutation = {
createManyViewFilter: AffectedRows; createManyViewFilter: AffectedRows;
createManyViewSort: AffectedRows; createManyViewSort: AffectedRows;
createOneActivity: Activity; createOneActivity: Activity;
createOneApiKey: AuthToken; createOneApiKey: ApiKeyToken;
createOneComment: Comment; createOneComment: Comment;
createOneCompany: Company; createOneCompany: Company;
createOneField: Field; createOneField: Field;
@@ -1402,6 +1409,7 @@ export type Mutation = {
createOnePipelineStage: PipelineStage; createOnePipelineStage: PipelineStage;
createOneView: View; createOneView: View;
createOneViewField: ViewField; createOneViewField: ViewField;
createOneWebHook: WebHook;
deleteCurrentWorkspace: Workspace; deleteCurrentWorkspace: Workspace;
deleteFavorite: Favorite; deleteFavorite: Favorite;
deleteManyActivities: AffectedRows; deleteManyActivities: AffectedRows;
@@ -1415,6 +1423,7 @@ export type Mutation = {
deleteOneObject: ObjectDeleteResponse; deleteOneObject: ObjectDeleteResponse;
deleteOnePipelineStage: PipelineStage; deleteOnePipelineStage: PipelineStage;
deleteOneView: View; deleteOneView: View;
deleteOneWebHook: WebHook;
deleteUserAccount: User; deleteUserAccount: User;
deleteWorkspaceMember: WorkspaceMember; deleteWorkspaceMember: WorkspaceMember;
impersonate: Verify; impersonate: Verify;
@@ -1559,6 +1568,11 @@ export type MutationCreateOneViewFieldArgs = {
}; };
export type MutationCreateOneWebHookArgs = {
data: WebHookCreateInput;
};
export type MutationDeleteFavoriteArgs = { export type MutationDeleteFavoriteArgs = {
where: FavoriteWhereInput; where: FavoriteWhereInput;
}; };
@@ -1609,6 +1623,11 @@ export type MutationDeleteOneViewArgs = {
}; };
export type MutationDeleteOneWebHookArgs = {
where: WebHookWhereUniqueInput;
};
export type MutationDeleteWorkspaceMemberArgs = { export type MutationDeleteWorkspaceMemberArgs = {
where: WorkspaceMemberWhereUniqueInput; where: WorkspaceMemberWhereUniqueInput;
}; };
@@ -2523,6 +2542,7 @@ export type Query = {
findManyViewField: Array<ViewField>; findManyViewField: Array<ViewField>;
findManyViewFilter: Array<ViewFilter>; findManyViewFilter: Array<ViewFilter>;
findManyViewSort: Array<ViewSort>; findManyViewSort: Array<ViewSort>;
findManyWebHook: Array<WebHook>;
findManyWorkspaceMember: Array<WorkspaceMember>; findManyWorkspaceMember: Array<WorkspaceMember>;
findUniqueCompany: Company; findUniqueCompany: Company;
findUniquePerson: Person; 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 = { export type QueryFindManyWorkspaceMemberArgs = {
cursor?: InputMaybe<WorkspaceMemberWhereUniqueInput>; cursor?: InputMaybe<WorkspaceMemberWhereUniqueInput>;
distinct?: InputMaybe<Array<WorkspaceMemberScalarFieldEnum>>; distinct?: InputMaybe<Array<WorkspaceMemberScalarFieldEnum>>;
@@ -3392,6 +3422,62 @@ export type ViewWhereUniqueInput = {
id?: InputMaybe<Scalars['String']>; 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 = { export type Workspace = {
__typename?: 'Workspace'; __typename?: 'Workspace';
Attachment?: Maybe<Array<Attachment>>; Attachment?: Maybe<Array<Attachment>>;
@@ -3415,6 +3501,7 @@ export type Workspace = {
viewFilters?: Maybe<Array<ViewFilter>>; viewFilters?: Maybe<Array<ViewFilter>>;
viewSorts?: Maybe<Array<ViewSort>>; viewSorts?: Maybe<Array<ViewSort>>;
views?: Maybe<Array<View>>; views?: Maybe<Array<View>>;
webHooks?: Maybe<Array<WebHook>>;
workspaceMember?: Maybe<Array<WorkspaceMember>>; workspaceMember?: Maybe<Array<WorkspaceMember>>;
}; };
@@ -3590,6 +3677,7 @@ export type WorkspaceUpdateInput = {
viewFilters?: InputMaybe<ViewFilterUpdateManyWithoutWorkspaceNestedInput>; viewFilters?: InputMaybe<ViewFilterUpdateManyWithoutWorkspaceNestedInput>;
viewSorts?: InputMaybe<ViewSortUpdateManyWithoutWorkspaceNestedInput>; viewSorts?: InputMaybe<ViewSortUpdateManyWithoutWorkspaceNestedInput>;
views?: InputMaybe<ViewUpdateManyWithoutWorkspaceNestedInput>; views?: InputMaybe<ViewUpdateManyWithoutWorkspaceNestedInput>;
webHooks?: InputMaybe<WebHookUpdateManyWithoutWorkspaceNestedInput>;
workspaceMember?: InputMaybe<WorkspaceMemberUpdateManyWithoutWorkspaceNestedInput>; 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 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 UserFieldsFragmentFragment = { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null };
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; 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 SearchUserQueryHookResult = ReturnType<typeof useSearchUserQuery>;
export type SearchUserLazyQueryHookResult = ReturnType<typeof useSearchUserLazyQuery>; export type SearchUserLazyQueryHookResult = ReturnType<typeof useSearchUserLazyQuery>;
export type SearchUserQueryResult = Apollo.QueryResult<SearchUserQuery, SearchUserQueryVariables>; 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` export const DeleteUserAccountDocument = gql`
mutation DeleteUserAccount { mutation DeleteUserAccount {
deleteUserAccount { deleteUserAccount {

View File

@@ -24,7 +24,7 @@ export const CompanyBoard = ({
onEditColumnTitle, onEditColumnTitle,
}: CompanyBoardProps) => { }: CompanyBoardProps) => {
// TODO: we can store objectId and fieldDefinitions in the ViewBarContext // 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 } = const { createView, deleteView, submitCurrentView, updateView } =
useBoardViews({ useBoardViews({
objectId: 'company', objectId: 'company',

View File

@@ -41,7 +41,7 @@ export const SettingsNavbar = () => {
end: false, end: false,
}); });
const isDevelopersSettingsActive = !!useMatch({ const isDevelopersSettingsActive = !!useMatch({
path: useResolvedPath('/settings/api').pathname, path: useResolvedPath('/settings/developers/api-keys').pathname,
end: true, end: true,
}); });
@@ -104,7 +104,7 @@ export const SettingsNavbar = () => {
{isDevelopersSettingsEnabled && ( {isDevelopersSettingsEnabled && (
<NavItem <NavItem
label="Developers" label="Developers"
to="/settings/apis" to="/settings/developers/api-keys"
Icon={IconRobot} Icon={IconRobot}
active={isDevelopersSettingsActive} 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}; color: ${({ theme }) => theme.font.color.tertiary};
`; `;
export const SettingsApisFieldItemTableRow = ({ export const SettingsApiKeysFieldItemTableRow = ({
fieldItem, fieldItem,
}: { }: {
fieldItem: ApisFiedlItem; 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(); 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 () => { const debouncedUpdate = debounce(async () => {
if (onFirstNameUpdate) { if (onFirstNameUpdate) {
onFirstNameUpdate(firstName); onFirstNameUpdate(firstName);

View File

@@ -34,7 +34,7 @@ export const NameField = ({
const [updateWorkspace] = useUpdateWorkspaceMutation(); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdate = useCallback( const debouncedUpdate = useCallback(
debounce(async (name: string) => { debounce(async (name: string) => {

View File

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

View File

@@ -9,5 +9,7 @@ export enum SettingsPath {
NewObject = 'objects/new', NewObject = 'objects/new',
WorkspaceMembersPage = 'workspace-members', WorkspaceMembersPage = 'workspace-members',
Workspace = 'workspace', 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 = { export type GenericFieldContextType = {
fieldDefinition: FieldDefinition<FieldMetadata>; fieldDefinition: FieldDefinition<FieldMetadata>;
// TODO: add better typing for mutation hook // TODO: add better typing for mutation web-hook
useUpdateEntityMutation: () => [(params: any) => void, any]; useUpdateEntityMutation: () => [(params: any) => void, any];
entityId: string; entityId: string;
recoilScopeId: string; recoilScopeId: string;

View File

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

View File

@@ -25,6 +25,7 @@ export type TextInputComponentProps = Omit<
> & { > & {
className?: string; className?: string;
label?: string; label?: string;
info?: string;
onChange?: (text: string) => void; onChange?: (text: string) => void;
fullWidth?: boolean; fullWidth?: boolean;
disableHotkeys?: boolean; disableHotkeys?: boolean;
@@ -45,10 +46,16 @@ const StyledLabel = styled.span`
text-transform: uppercase; 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` const StyledInputContainer = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 100%; width: 100%;
`; `;
@@ -113,6 +120,7 @@ const TextInputComponent = (
{ {
className, className,
label, label,
info,
value, value,
onChange, onChange,
onFocus, onFocus,
@@ -204,6 +212,7 @@ const TextInputComponent = (
)} )}
</StyledTrailingIconContainer> </StyledTrailingIconContainer>
</StyledInputContainer> </StyledInputContainer>
{info && <StyledInfo>{info}</StyledInfo>}
{error && <StyledErrorHelper>{error}</StyledErrorHelper>} {error && <StyledErrorHelper>{error}</StyledErrorHelper>}
</StyledContainer> </StyledContainer>
); );

View File

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

View File

@@ -38,3 +38,7 @@ export const Filled: Story = {
export const Disabled: Story = { export const Disabled: Story = {
args: { disabled: true, value: 'Tim' }, 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'; 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. // boardColumnTotalsFamilyState indexed by columnId.
export const boardColumnTotalsFamilySelector = selectorFamily({ export const boardColumnTotalsFamilySelector = selectorFamily({
key: 'boardColumnTotalsFamilySelector', 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 { getByText } = render(<TestComponentDomMode />);
const inside = getByText('Inside'); const inside = getByText('Inside');
const inside2 = getByText('Inside 2'); const inside2 = getByText('Inside 2');

View File

@@ -136,7 +136,7 @@ export const CreateProfile = () => {
title="Name" title="Name"
description="Your name as it will be displayed on the app" 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> <StyledComboInputContainer>
<Controller <Controller
name="firstName" 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 styled from '@emotion/styled';
import { objectSettingsWidth } from '@/settings/data-model/constants/objectSettings'; 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 { activeApiKeyItems } from '@/settings/developers/constants/mockObjects';
import { IconPlus, IconSettings } from '@/ui/display/icon'; import { IconPlus, IconSettings } from '@/ui/display/icon';
import { H1Title } from '@/ui/display/typography/components/H1Title'; import { H1Title } from '@/ui/display/typography/components/H1Title';
@@ -34,7 +34,7 @@ const StyledH1Title = styled(H1Title)`
margin-bottom: 0; margin-bottom: 0;
`; `;
export const SettingsApis = () => { export const SettingsDevelopersApiKeys = () => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
@@ -48,7 +48,7 @@ export const SettingsApis = () => {
accent="blue" accent="blue"
size="small" size="small"
onClick={() => { onClick={() => {
navigate('/'); navigate('/settings/developers/api-keys/new');
}} }}
/> />
</StyledHeader> </StyledHeader>
@@ -64,7 +64,7 @@ export const SettingsApis = () => {
<TableHeader></TableHeader> <TableHeader></TableHeader>
</StyledTableRow> </StyledTableRow>
{activeApiKeyItems.map((fieldItem) => ( {activeApiKeyItems.map((fieldItem) => (
<SettingsApisFieldItemTableRow <SettingsApiKeysFieldItemTableRow
key={fieldItem.id} key={fieldItem.id}
fieldItem={fieldItem} 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'; import { FullHeightStorybookLayout } from '../FullHeightStorybookLayout';
export type PageDecoratorArgs = { routePath: string; routeParams: RouteParams }; export type PageDecoratorArgs = {
routePath: string;
routeParams: RouteParams;
state?: string;
};
type RouteParams = { type RouteParams = {
[param: string]: string; [param: string]: string;
}; };
const computeLocation = (routePath: string, routeParams: RouteParams) => const computeLocation = (
routePath.replace(/:(\w+)/g, (paramName) => routeParams[paramName] ?? ''); routePath: string,
routeParams: RouteParams,
state?: string,
) => {
return {
pathname: routePath.replace(
/:(\w+)/g,
(paramName) => routeParams[paramName] ?? '',
),
state,
};
};
export const PageDecorator: Decorator<{ export const PageDecorator: Decorator<{
routePath: string; routePath: string;
routeParams: RouteParams; routeParams: RouteParams;
state?: string;
}> = (Story, { args }) => ( }> = (Story, { args }) => (
<UserProvider> <UserProvider>
<ClientConfigProvider> <ClientConfigProvider>
<MemoryRouter <MemoryRouter
initialEntries={[computeLocation(args.routePath, args.routeParams)]} initialEntries={[
computeLocation(args.routePath, args.routeParams, args.state),
]}
> >
<FullHeightStorybookLayout> <FullHeightStorybookLayout>
<HelmetProvider> <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_COMPANY_QUERY } from '@/search/graphql/queries/searchCompanyQuery';
import { SEARCH_PEOPLE_QUERY } from '@/search/graphql/queries/searchPeopleQuery'; import { SEARCH_PEOPLE_QUERY } from '@/search/graphql/queries/searchPeopleQuery';
import { SEARCH_USER_QUERY } from '@/search/graphql/queries/searchUserQuery'; 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_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import { GET_VIEW_FIELDS } from '@/views/graphql/queries/getViewFields'; import { GET_VIEW_FIELDS } from '@/views/graphql/queries/getViewFields';
import { GET_VIEWS } from '@/views/graphql/queries/getViews'; import { GET_VIEWS } from '@/views/graphql/queries/getViews';
@@ -30,6 +31,7 @@ import {
SearchUserQuery, SearchUserQuery,
ViewType, ViewType,
} from '~/generated/graphql'; } from '~/generated/graphql';
import { mockedApiKeys } from '~/testing/mock-data/api-keys';
import { mockedActivities, mockedTasks } from './mock-data/activities'; import { mockedActivities, mockedTasks } from './mock-data/activities';
import { 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( graphql.mutation(
getOperationName(CREATE_ACTIVITY_WITH_COMMENT) ?? '', getOperationName(CREATE_ACTIVITY_WITH_COMMENT) ?? '',
(req, res, ctx) => { (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 { DateTime } from 'luxon';
import { import {
beautifyDateDiff,
beautifyExactDate, beautifyExactDate,
beautifyExactDateTime, beautifyExactDateTime,
beautifyPastDateAbsolute, beautifyPastDateAbsolute,
@@ -237,3 +238,47 @@ describe('hasDatePassed', () => {
expect(result).toEqual(false); 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; 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', label: 'Api Key',
type: 'string', type: 'string',
helpText: 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}}', connectionLabel: '{{data.currentWorkspace.displayName}}',

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,14 +16,14 @@ import { AbilityAction } from 'src/ability/ability.action';
import { assert } from 'src/utils/assert'; import { assert } from 'src/utils/assert';
@Injectable() @Injectable()
export class CreateHookAbilityHandler implements IAbilityHandler { export class CreateWebHookAbilityHandler implements IAbilityHandler {
constructor(private readonly prismaService: PrismaService) {} constructor(private readonly prismaService: PrismaService) {}
async handle(ability: AppAbility, context: ExecutionContext) { async handle(ability: AppAbility, context: ExecutionContext) {
const gqlContext = GqlExecutionContext.create(context); const gqlContext = GqlExecutionContext.create(context);
const args = gqlContext.getArgs(); const args = gqlContext.getArgs();
const allowed = await relationAbilityChecker( const allowed = await relationAbilityChecker(
'Hook', 'WebHook',
ability, ability,
this.prismaService.client, this.prismaService.client,
args, args,
@@ -31,27 +31,27 @@ export class CreateHookAbilityHandler implements IAbilityHandler {
if (!allowed) { if (!allowed) {
return false; return false;
} }
return ability.can(AbilityAction.Create, 'Hook'); return ability.can(AbilityAction.Create, 'WebHook');
} }
} }
@Injectable() @Injectable()
export class DeleteHookAbilityHandler implements IAbilityHandler { export class DeleteWebHookAbilityHandler implements IAbilityHandler {
constructor(private readonly prismaService: PrismaService) {} constructor(private readonly prismaService: PrismaService) {}
async handle(ability: AppAbility, context: ExecutionContext) { async handle(ability: AppAbility, context: ExecutionContext) {
const gqlContext = GqlExecutionContext.create(context); const gqlContext = GqlExecutionContext.create(context);
const args = gqlContext.getArgs(); const args = gqlContext.getArgs();
const hook = await this.prismaService.client.hook.findFirst({ const hook = await this.prismaService.client.webHook.findFirst({
where: args.where, where: args.where,
}); });
assert(hook, '', NotFoundException); assert(hook, '', NotFoundException);
return ability.can(AbilityAction.Delete, subject('Hook', hook)); return ability.can(AbilityAction.Delete, subject('WebHook', hook));
} }
} }
@Injectable() @Injectable()
export class ReadHookAbilityHandler implements IAbilityHandler { export class ReadWebHookAbilityHandler implements IAbilityHandler {
async handle(ability: AppAbility) { 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 { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { UserAbility } from 'src/decorators/user-ability.decorator'; import { UserAbility } from 'src/decorators/user-ability.decorator';
import { AppAbility } from 'src/ability/ability.factory'; 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'; import { ApiKeyService } from './api-key.service';
@@ -28,13 +28,13 @@ import { ApiKeyService } from './api-key.service';
export class ApiKeyResolver { export class ApiKeyResolver {
constructor(private readonly apiKeyService: ApiKeyService) {} constructor(private readonly apiKeyService: ApiKeyService) {}
@Mutation(() => AuthToken) @Mutation(() => ApiKeyToken)
@UseGuards(AbilityGuard) @UseGuards(AbilityGuard)
@CheckAbilities(CreateApiKeyAbilityHandler) @CheckAbilities(CreateApiKeyAbilityHandler)
async createOneApiKey( async createOneApiKey(
@Args() args: CreateOneApiKeyArgs, @Args() args: CreateOneApiKeyArgs,
@AuthWorkspace() { id: workspaceId }: Workspace, @AuthWorkspace() { id: workspaceId }: Workspace,
): Promise<AuthToken> { ): Promise<ApiKeyToken> {
return await this.apiKeyService.generateApiKeyToken( return await this.apiKeyService.generateApiKeyToken(
workspaceId, workspaceId,
args.data.name, args.data.name,

View File

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

View File

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

View File

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

View File

@@ -2,11 +2,10 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/database/prisma.module'; import { PrismaModule } from 'src/database/prisma.module';
import { AbilityModule } from 'src/ability/ability.module'; import { AbilityModule } from 'src/ability/ability.module';
import { WebHookResolver } from 'src/core/web-hook/web-hook.resolver';
import { HookResolver } from './hook.resolver';
@Module({ @Module({
imports: [PrismaModule, AbilityModule], 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 { accessibleBy } from '@casl/prisma';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; 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 { AbilityGuard } from 'src/guards/ability.guard';
import { CheckAbilities } from 'src/decorators/check-abilities.decorator'; import { CheckAbilities } from 'src/decorators/check-abilities.decorator';
import { import {
CreateHookAbilityHandler, CreateWebHookAbilityHandler,
DeleteHookAbilityHandler, DeleteWebHookAbilityHandler,
ReadHookAbilityHandler, ReadWebHookAbilityHandler,
} from 'src/ability/handlers/hook.ability-handler'; } from 'src/ability/handlers/web-hook.ability-handler';
import { CreateOneHookArgs } from 'src/core/@generated/hook/create-one-hook.args';
import { PrismaService } from 'src/database/prisma.service'; import { PrismaService } from 'src/database/prisma.service';
import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
import { Workspace } from 'src/core/@generated/workspace/workspace.model'; 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 { UserAbility } from 'src/decorators/user-ability.decorator';
import { AppAbility } from 'src/ability/ability.factory'; 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) @UseGuards(JwtAuthGuard)
@Resolver(() => Hook) @Resolver(() => WebHook)
export class HookResolver { export class WebHookResolver {
constructor(private readonly prismaService: PrismaService) {} constructor(private readonly prismaService: PrismaService) {}
@Mutation(() => Hook) @Mutation(() => WebHook)
@UseGuards(AbilityGuard) @UseGuards(AbilityGuard)
@CheckAbilities(CreateHookAbilityHandler) @CheckAbilities(CreateWebHookAbilityHandler)
async createOneHook( async createOneWebHook(
@Args() args: CreateOneHookArgs, @Args() args: CreateOneWebHookArgs,
@AuthWorkspace() { id: workspaceId }: Workspace, @AuthWorkspace() { id: workspaceId }: Workspace,
): Promise<Hook> { ): Promise<WebHook> {
return this.prismaService.client.hook.create({ return this.prismaService.client.webHook.create({
data: { data: {
...args.data, ...args.data,
...{ workspace: { connect: { id: workspaceId } } }, ...{ workspace: { connect: { id: workspaceId } } },
@@ -40,31 +40,31 @@ export class HookResolver {
}); });
} }
@Mutation(() => Hook, { nullable: false }) @Mutation(() => WebHook, { nullable: false })
@UseGuards(AbilityGuard) @UseGuards(AbilityGuard)
@CheckAbilities(DeleteHookAbilityHandler) @CheckAbilities(DeleteWebHookAbilityHandler)
async deleteOneHook(@Args() args: DeleteOneHookArgs): Promise<Hook> { async deleteOneWebHook(@Args() args: DeleteOneWebHookArgs): Promise<WebHook> {
const hookToDelete = this.prismaService.client.hook.findUnique({ const hookToDelete = this.prismaService.client.webHook.findUnique({
where: args.where, where: args.where,
}); });
if (!hookToDelete) { if (!hookToDelete) {
throw new NotFoundException(); throw new NotFoundException();
} }
return await this.prismaService.client.hook.delete({ return await this.prismaService.client.webHook.delete({
where: args.where, where: args.where,
}); });
} }
@Query(() => [Hook]) @Query(() => [WebHook])
@UseGuards(AbilityGuard) @UseGuards(AbilityGuard)
@CheckAbilities(ReadHookAbilityHandler) @CheckAbilities(ReadWebHookAbilityHandler)
async findManyHook( async findManyWebHook(
@Args() args: FindManyHookArgs, @Args() args: FindManyWebHookArgs,
@UserAbility() ability: AppAbility, @UserAbility() ability: AppAbility,
) { ) {
const filterOptions = [accessibleBy(ability).WorkspaceMember]; const filterOptions = [accessibleBy(ability).WorkspaceMember];
if (args.where) filterOptions.push(args.where); if (args.where) filterOptions.push(args.where);
return this.prismaService.client.hook.findMany({ return this.prismaService.client.webHook.findMany({
...args, ...args,
where: { AND: filterOptions }, 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[] views View[]
viewSorts ViewSort[] viewSorts ViewSort[]
apiKeys ApiKey[] apiKeys ApiKey[]
hooks Hook[] webHooks WebHook[]
/// @TypeGraphQL.omit(input: true, output: true) /// @TypeGraphQL.omit(input: true, output: true)
deletedAt DateTime? deletedAt DateTime?
@@ -910,7 +910,7 @@ model ApiKey {
@@map("api_keys") @@map("api_keys")
} }
model Hook { model WebHook {
/// @Validator.IsString() /// @Validator.IsString()
/// @Validator.IsOptional() /// @Validator.IsOptional()
id String @id @default(uuid()) id String @id @default(uuid())
@@ -925,5 +925,5 @@ model Hook {
/// @TypeGraphQL.omit(input: true, output: true) /// @TypeGraphQL.omit(input: true, output: true)
deletedAt DateTime? deletedAt DateTime?
@@map("hooks") @@map("web_hooks")
} }

View File

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