Compare commits

...

154 Commits

Author SHA1 Message Date
Ali BARIN
424c251dde test: ntfy 2024-07-27 13:07:54 +00:00
Ali BARIN
be4493710f feat(http-client): support async beforeRequest interceptors 2024-07-27 13:05:04 +00:00
Ömer Faruk Aydın
52c0c5e0c5 Merge pull request #1992 from automatisch/fix-proxy-configuration
fix(axios): update order of interceptors
2024-07-26 20:16:38 +02:00
Ali BARIN
4c639f170e fix(axios): update order of interceptors 2024-07-26 13:52:41 +00:00
Ali BARIN
509a414151 Merge pull request #1986 from automatisch/aut-1126
refactor(http-client): inherit interceptors from parent instance
2024-07-26 11:45:02 +02:00
Ali BARIN
8f3c793a69 Merge pull request #1988 from automatisch/aut-1130
feat(formatter/text): add regex support in replace transfomer
2024-07-26 11:21:12 +02:00
Ömer Faruk Aydın
a2dd9cf1b8 Merge pull request #1989 from automatisch/fix-typos-in-appwrite-docs
docs(appwrite): fix typos
2024-07-26 10:14:15 +02:00
Ömer Faruk Aydın
42285a5879 Merge pull request #1987 from automatisch/aut-1129
fix(webhook): add missing filter coverage
2024-07-26 10:13:40 +02:00
Ali BARIN
5d63fce6f0 docs(appwrite): fix typos 2024-07-25 09:18:25 +00:00
Ali BARIN
46a4c8faec feat(formatter/text): add regex support in replace transfomer 2024-07-24 17:34:14 +00:00
Ali BARIN
ba14481151 fix(webhook): add missing filter coverage 2024-07-24 16:01:28 +00:00
Ali BARIN
bdd8da98c4 Merge pull request #1985 from automatisch/aut-1061
feat: use dynamic DOCS URL
2024-07-24 14:20:03 +02:00
Ali BARIN
5a4207414d refactor(http-client): inherit interceptors from parent instance 2024-07-23 15:01:12 +00:00
Ali BARIN
730fdd32b1 feat: use dynamic DOCS URL 2024-07-23 09:48:20 +00:00
Ali BARIN
aa7f6694fc Merge pull request #1974 from kuba618/AUT-1115
test: add admin account setup tests
2024-07-22 17:03:55 +02:00
Jakub P.
8fba6704df Merge remote-tracking branch 'upstream/main' into AUT-1115 2024-07-22 16:32:09 +02:00
Jakub P.
37d02eba02 test: add teardown dependency to clean db after tests 2024-07-22 15:42:21 +02:00
Ali BARIN
75a87cf070 Merge pull request #1965 from automatisch/add-cryptography-app
feat: add cryptography app with createHmac and createSignature actions
2024-07-22 14:22:14 +02:00
Ömer Faruk Aydın
8acdc5853d Merge pull request #1978 from automatisch/AUT-1105
feat: refactor delete user mutation with the REST API endpoint
2024-07-22 14:11:35 +02:00
kasia.oczkowska
1aa1f441b3 feat: refactor delete user mutation with the REST API endpoint 2024-07-22 13:27:57 +02:00
Ömer Faruk Aydın
0e26032ac3 Merge pull request #1977 from automatisch/add-create-uuid-text-formatter
feat(formatter/text): add create uuid action
2024-07-22 13:24:14 +02:00
Ömer Faruk Aydın
871f25c6d9 Merge pull request #1975 from automatisch/add-get-current-timestamp
feat(formatter/date-time): add get current timestamp action
2024-07-22 13:23:06 +02:00
Ömer Faruk Aydın
d8199e7ba7 Merge pull request #1983 from automatisch/strict-installation-redirection
fix: safeguard redirect to /installation
2024-07-22 13:20:25 +02:00
Ali BARIN
52b938eabe Merge pull request #1980 from kuba618/AUT-1119
test: add removed user invitation test
2024-07-19 11:42:29 +02:00
Ali BARIN
94fddf3d9b Merge pull request #1979 from kuba618/AUT-1118
chore: add linter to tests
2024-07-19 11:41:34 +02:00
Ali BARIN
02c98c1ece Merge pull request #1982 from automatisch/remove-deleted-connection
fix(AppConnectionRow): invalidate app connections upon deletion
2024-07-19 11:38:31 +02:00
Ali BARIN
6ba77667e9 Merge pull request #1981 from automatisch/AUT-1103
feat: use create access token api endpoint instead of login mutation
2024-07-19 11:38:17 +02:00
Ali BARIN
e201a5b806 fix: safeguard redirect to /installation 2024-07-19 09:31:58 +00:00
Ali BARIN
adac68c407 fix(AppConnectionRow): invalidate app connections upon deletion 2024-07-19 08:43:49 +00:00
kasia.oczkowska
d051275e54 feat: use create access token api endpoint instead of login mutation 2024-07-18 13:05:59 +01:00
Jakub P.
2afc00364a test: add removed user invitation test 2024-07-18 12:40:48 +02:00
Jakub P.
0a1461231b chore: add linter to tests 2024-07-18 12:39:04 +02:00
Jakub P.
129327f40d test: add admin account setup tests 2024-07-18 12:29:35 +02:00
Ali BARIN
58819aad94 feat(cryptography/actions): add output encoding field 2024-07-17 18:44:00 +02:00
Ali BARIN
949a2543f5 feat(cryptography): add create signature action 2024-07-17 18:44:00 +02:00
Ali BARIN
0f9d732667 feat: add cryptography app with createHmac action 2024-07-17 18:44:00 +02:00
Ali BARIN
b7df175950 feat(formatter/text): add create uuid action 2024-07-17 16:07:23 +00:00
Ali BARIN
4f7ce9874f feat(formatter/date-time): add get current timestamp action 2024-07-17 15:56:26 +00:00
Ömer Faruk Aydın
f63cc80383 Merge pull request #1973 from automatisch/AUT-1109
feat: refactor reset password mutation with the REST API endpoint
2024-07-17 17:24:55 +02:00
kasia.oczkowska
dd0a1328e8 feat: refactor reset password mutation with the REST API endpoint 2024-07-17 15:05:46 +01:00
Ali BARIN
27610c002c Merge pull request #1972 from automatisch/AUT-1107
feat: refactor forgot password mutation with the REST API endpoint
2024-07-17 15:32:37 +02:00
kasia.oczkowska
46ec9b5229 feat: refactor forgot password mutation with the REST API endpoint 2024-07-17 13:23:45 +01:00
Ali BARIN
778559d537 Merge pull request #1971 from automatisch/fix-email-worker
fix(email): correct sandbox safeguard check
2024-07-17 13:53:12 +02:00
Ali BARIN
1e64b8a903 fix(email): correct sandbox safeguard check 2024-07-17 10:25:01 +02:00
Ali BARIN
0e000693d6 Merge pull request #1970 from automatisch/rest-reset-password
feat: Implement reset password rest API endpoint
2024-07-16 16:45:20 +02:00
Faruk AYDIN
cf9e09ea7a feat: Implement reset password rest API endpoint 2024-07-16 16:22:10 +02:00
Ali BARIN
eac2f729a5 Merge pull request #1969 from automatisch/fix-migrations-rollback
fix: Remove step constraint down migration
2024-07-16 16:04:07 +02:00
Faruk AYDIN
9578cd27dd fix: Remove step contraint down migration 2024-07-16 15:52:24 +02:00
Ali BARIN
9304ffffc9 Merge pull request #1968 from automatisch/rest-forgot-password
feat: Implement forgot password rest API endpoint
2024-07-16 15:52:07 +02:00
Faruk AYDIN
3fd628cb05 feat: Implement forgot password rest API endpoint 2024-07-16 15:43:15 +02:00
Ömer Faruk Aydın
fe68b70bd9 Merge pull request #1967 from automatisch/rest-delete-user
feat: Implement rest API endpoint to delete user
2024-07-16 15:42:01 +02:00
Faruk AYDIN
ec2863d218 feat: Implement rest API endpoint to delete user 2024-07-16 15:18:01 +02:00
Ali BARIN
dc56e7f883 Merge pull request #1966 from kuba618/AUT-1099
test: add accept invitation invalid token tests
2024-07-16 13:53:32 +02:00
Jakub P.
2f1f537e00 test: add accept invitation invalid token tests 2024-07-16 12:08:50 +02:00
Ali BARIN
b7b3a3025b Merge pull request #1964 from automatisch/AUT-1095
feat: create onboarding UX flow
2024-07-15 16:11:25 +02:00
Ali BARIN
edb9526538 feat(InstallationForm): rephrase submit button 2024-07-15 12:24:35 +00:00
Ali BARIN
62117ece06 Merge pull request #1939 from automatisch/aut-1035
feat(salesforce): add find partially matching record action
2024-07-15 14:01:21 +02:00
Ali BARIN
913a2a0ac1 Merge pull request #1953 from automatisch/add-security-icon-in-additional-drawer
feat(Layout): support security icon in additional drawer link
2024-07-15 14:01:10 +02:00
Ali BARIN
92ec3d07a3 feat(salesforce/find-partially-matching-record): sanitize user inputs 2024-07-15 09:04:16 +00:00
Ali BARIN
67ee7899fd feat(salesforce): add find partially matching record action 2024-07-15 09:04:16 +00:00
Ali BARIN
b0ceb3fe7e feat(InstallationForm): use thinner font weight in success alert 2024-07-15 09:00:13 +00:00
kasia.oczkowska
ba6b4c6854 feat: create onboarding UX flow 2024-07-12 14:55:30 +01:00
Ali BARIN
61e90aed60 chore: upgrade @playwright/test with 1.45.1 2024-07-12 13:40:51 +00:00
Ali BARIN
45b7a399f2 feat(Layout): support security icon in additional drawer link 2024-07-12 13:40:22 +00:00
Ali BARIN
673ed25598 Merge pull request #1963 from automatisch/add-parse-stringified-json-action
feat(formatter): add parse stringified json action
2024-07-12 12:44:49 +02:00
Ali BARIN
14fc460174 Merge pull request #1962 from automatisch/missing-power-input-label
fix: bring back the label for PowerInput
2024-07-12 12:38:54 +02:00
Ali BARIN
211b4537f9 Merge pull request #1961 from automatisch/fix-undefined-datastore-entry-throwing
fix(datastore): stop undefined datastore entry throwing
2024-07-12 12:37:53 +02:00
Ali BARIN
a683e059aa feat(formatter): add parse stringified json action 2024-07-12 10:37:16 +00:00
kasia.oczkowska
d2d2cea567 fix: return Item in renderItemFactory 2024-07-12 11:31:43 +01:00
kasia.oczkowska
e5bda65a35 fix: bring back the label for PowerInput 2024-07-12 11:27:52 +01:00
Ali BARIN
6aaef9df4b fix(datastore): stop undefined datastore entry throwing 2024-07-12 08:58:39 +00:00
Ali BARIN
7fba4d72b0 Merge pull request #1958 from automatisch/AUT-1091
fix: introduce fix for logo being stretched
2024-07-11 15:35:48 +02:00
Ali BARIN
a6dc2ed4ba Merge pull request #1956 from automatisch/AUT-1007
feat: limit vertical scroll to a reasonable boundary
2024-07-11 15:34:04 +02:00
Ali BARIN
20a40eb2f6 Merge pull request #1959 from automatisch/AUT-1088
fix: introduce fix for undefined id on dialog close when adding a connection
2024-07-11 13:23:59 +02:00
kasia.oczkowska
ec4ac9d075 fix: introduce fix for undefined id on dialog close when adding a connection 2024-07-11 12:15:51 +01:00
kasia.oczkowska
b2c14f4226 fix: introduce fix for logo being stretched 2024-07-11 11:28:45 +01:00
Ömer Faruk Aydın
099dfbd0b0 Merge pull request #1954 from automatisch/user-invitation
feat: Implement user invitation functionality
2024-07-11 12:18:20 +02:00
Ali BARIN
a9f5736c12 test: cover accept invitation flow 2024-07-11 11:38:23 +02:00
Ali BARIN
2bd4dd3ab0 test(e2e-tests): cover password field removal 2024-07-11 11:38:23 +02:00
Ali BARIN
186850c256 test: cover user status in user serializer 2024-07-11 11:38:23 +02:00
Ali BARIN
9922033d33 feat: add accept-invitation page 2024-07-11 11:38:23 +02:00
Faruk AYDIN
3c3e6e4144 feat: Implement user invitation backend functionality 2024-07-11 11:38:23 +02:00
Faruk AYDIN
0e4ac3b7f3 feat: Add status column to user model 2024-07-11 11:38:23 +02:00
Ali BARIN
c9813e0316 Merge pull request #1950 from automatisch/AUT-1074
fix: fix error for only Create role when publishing flow
2024-07-11 11:02:08 +02:00
Ali BARIN
577c0cfab9 Merge pull request #1957 from automatisch/AUT-677
feat: add missing propTypes
2024-07-11 10:54:17 +02:00
kasia.oczkowska
0966f9d715 feat: disable publish button when no flow or permissions 2024-07-11 09:39:31 +01:00
kasia.oczkowska
3f5df118a0 feat: add missing propTypes 2024-07-11 08:54:32 +01:00
kasia.oczkowska
e05c1b26f1 feat: limit vertical scroll to a reasonable boundary 2024-07-10 14:52:07 +01:00
Ömer Faruk Aydın
725b38c697 Merge pull request #1955 from automatisch/repository-structure
docs: Adjust repository to the new packages structure
2024-07-09 13:14:27 +02:00
Faruk AYDIN
402a0fdf3b docs: Adjust repository to the new packages structure 2024-07-08 21:11:25 +02:00
Ali BARIN
078364ffa1 Merge pull request #1951 from automatisch/AUT-1069
fix: set default locale for luxon's  DateTime
2024-07-08 15:19:14 +02:00
kasia.oczkowska
f64d5ec4fc fix: set default locale for luxon's DateTime 2024-07-04 15:14:28 +01:00
kasia.oczkowska
0666174501 fix: fix error for only Create role when publishing flow 2024-07-04 14:08:35 +01:00
Ali BARIN
12194a50e1 Merge pull request #1948 from automatisch/AUT-1076
feat: block access to flow creation for users without permissions
2024-07-04 10:44:15 +02:00
kasia.oczkowska
82ee592699 feat: block access to flow creation for users without permissions 2024-07-04 08:57:03 +01:00
Ali BARIN
1b4fb2ce6e Merge pull request #1947 from automatisch/AUT-1070
feat: use try-catch block for updating flow status
2024-07-03 15:37:02 +02:00
kasia.oczkowska
ebea8d12d1 feat: use try-catch block for updating flow status 2024-07-03 14:04:11 +01:00
Ali BARIN
f842dd77df Merge pull request #1945 from automatisch/remove-access-tokens-in-perm-user-deletion
fix: delete access token before hard deleting user
2024-07-03 13:16:20 +02:00
Ali BARIN
a6ec7a6c99 Merge pull request #1944 from automatisch/fix-reset-password
fix(user): make resetPasswordToken relevant fields nullable
2024-07-03 13:16:12 +02:00
Ali BARIN
369c72282c fix: delete access token before hard deleting user 2024-07-01 11:41:11 +00:00
Ali BARIN
6f30c1a509 fix(user): make resetPasswordToken relevant fields nullable 2024-07-01 10:26:17 +00:00
Ali BARIN
abfd1116c7 Merge pull request #1941 from automatisch/AUT-1038
fix: persist value in ControlledCustomAutocomplete when it depends on other fields
2024-06-21 14:32:09 +02:00
kasia.oczkowska
017854955d fix: persist value in ControlledCustomAutocomplete when it depends on other fields 2024-06-21 10:37:39 +01:00
Ali BARIN
1405cddea1 Merge pull request #1940 from automatisch/AUT-1039
Revert "feat: persist parameters values in FlowSubstep (#1505)"
2024-06-20 15:57:47 +02:00
kasia.oczkowska
00dd3164c9 Revert "feat: persist parameters values in FlowSubstep (#1505)"
This reverts commit 017a881494.
2024-06-20 14:48:51 +01:00
Ali BARIN
d5cbc0f611 Merge pull request #1578 from automatisch/AUT-620
feat: improve UI display depending on user permissions
2024-06-20 12:04:31 +02:00
kasia.oczkowska
5d2e9ccc67 feat: improve UI display depending on user persmissions 2024-06-20 09:52:00 +00:00
kattoczko
017a881494 feat: persist parameters values in FlowSubstep (#1505)
* feat: persist parameters values in FlowSubstep

* feat: add missing import

* feat: use usePrevious hook
2024-06-20 11:13:45 +02:00
Alex Maslakov
52994970e6 fix(DynamicField): display long variables better
* Fix issue #1933

Problem with rendering in DynamicField component with long variables #1933

* Fix issue #1933 (2)

* Fix issue #1933 (3)
2024-06-20 09:55:08 +02:00
Ali BARIN
ebae629e5c Merge pull request #1931 from automatisch/AUT-1010
feat: improve nodes and edges state update
2024-06-19 16:01:24 +02:00
kasia.oczkowska
4d79220b0c refactor: fix spelling and wording errors 2024-06-14 12:39:42 +01:00
kasia.oczkowska
96fba7fbb8 feat: improve nodes and edges state update 2024-06-14 12:39:42 +01:00
Ali BARIN
e0d610071d Merge pull request #1917 from automatisch/AUT-1009
feat: hide react-flow attribution
2024-06-05 15:35:09 +02:00
kasia.oczkowska
ab0966c005 feat: hide react-flow attribution 2024-06-05 13:39:49 +01:00
Ali BARIN
751eb41e72 Merge pull request #1817 from automatisch/new-editor-feature-flag
feat: introduce feature flag for new flow editor
2024-06-04 12:45:00 +02:00
kasia.oczkowska
f08dc25711 feat: introduce style and behavior improvements 2024-06-04 07:18:18 +00:00
kasia.oczkowska
737eb31776 feat: introduce custom edges, auto layout improvements and node data updates 2024-06-04 07:17:38 +00:00
kasia.oczkowska
d6abf283bc feat: introduce automatic layout for new flow editor 2024-06-04 07:17:38 +00:00
kasia.oczkowska
bac4ab5aa4 feat: introduce feature flag for new flow editor 2024-06-04 07:17:38 +00:00
Ali BARIN
b5839390fd Merge pull request #1911 from automatisch/render-yaml-fix
fix(render.yaml): correct docker contexts
2024-06-03 11:21:31 +02:00
Ali BARIN
d19271dae1 fix(render.yaml): correct docker contexts 2024-06-03 10:23:28 +02:00
Ali BARIN
ef5a09314e Merge pull request #1907 from automatisch/fix-admin-apps-undefined-error
fix(AdminApplicationSettings): handle undefined appConfig object
2024-05-31 13:52:13 +02:00
Rıdvan Akca
ba52e298eb fix(AdminApplicationSettings): handle undefined appConfig object 2024-05-31 13:26:06 +02:00
Ali BARIN
b3c3998189 Merge pull request #1895 from automatisch/fix-appkey-error-in-flowrow
fix: remove unnecessary appKey in FlowRow and FlowContextMenu
2024-05-31 12:54:13 +02:00
Ömer Faruk Aydın
782f9b5c04 Merge pull request #1900 from automatisch/release/v0.12.0
release(v0.12.0): Update version to 0.12.0
2024-05-28 12:05:52 +02:00
Faruk AYDIN
3079d8c605 Update version to 0.12.0 2024-05-28 11:13:54 +02:00
Rıdvan Akca
c5202d7b3e fix: remove unnecessary appKey in FlowRow and FlowContextMenu 2024-05-24 14:11:22 +02:00
Ali BARIN
fbae83f4de Merge pull request #1874 from automatisch/make-value-column-text-in-datastore
fix(datastore): make value column text
2024-05-23 10:13:47 +02:00
Ali BARIN
5b7b8c934f Merge pull request #1890 from automatisch/unix-to-date-format
feat(formatter/format-date-time): add unix to datetime support
2024-05-17 15:15:06 +02:00
Rıdvan Akca
b70223e824 feat(formatter/format-date-time): add unix to datetime support 2024-05-17 15:02:22 +02:00
Ali BARIN
9900bbbc8d Merge pull request #1889 from automatisch/fix-attribute-typo 2024-05-17 14:09:01 +02:00
Rıdvan Akca
fc7f1ddd69 fix(appwrite): fix attribute typo 2024-05-17 13:47:16 +02:00
Ali BARIN
991b2f4bf7 Merge pull request #1887 from automatisch/AUT-982
feat: implement propTypes for FlowSubstep and FlowSubstepTitle
2024-05-17 13:13:03 +02:00
Ali BARIN
bb251b16a9 Merge pull request #1556 from automatisch/AUT-610
feat(appwrite): add appwrite integration
2024-05-15 21:49:23 +02:00
Ali BARIN
1b34a48a61 refactor(appwrite/dynamic-data): use native API ordering 2024-05-15 17:59:56 +00:00
Ali BARIN
8c83b715fe fix(appwrite/new-documents): add native order and pagination support 2024-05-15 17:59:56 +00:00
Ali BARIN
c122708b0b fix(appwrite): utilize DOCS_URL 2024-05-15 17:59:56 +00:00
Ali BARIN
258d920ff2 docs(appwrite): describe project settings and hostname 2024-05-15 17:59:56 +00:00
Rıdvan Akca
6e5c0cc0c7 feat(appwrite): add new documents trigger 2024-05-15 17:59:54 +00:00
Rıdvan Akca
9dc82290b5 feat(appwrite): add appwrite integration 2024-05-15 17:59:39 +00:00
Ali BARIN
bb73f90374 Merge pull request #1888 from automatisch/fix-e2e-tests
test: update first app path
2024-05-15 17:59:59 +02:00
Ali BARIN
8a0720b0e3 test: update first app path 2024-05-15 15:52:02 +00:00
Ali BARIN
88468c4f89 Merge pull request #1551 from automatisch/AUT-599
feat(airtable): add airtable integration
2024-05-15 17:35:55 +02:00
Ali BARIN
d19a45592f docs(airtable): link to airtable for apps 2024-05-15 15:30:48 +00:00
Ali BARIN
21a921d25d fix(airtable): remove user ID out of screen name 2024-05-15 15:30:36 +00:00
Ali BARIN
3b2946aac5 fix(airtable/find-record): make limitToView optional field 2024-05-15 15:30:23 +00:00
Ali BARIN
196d555e8c fix(airtable): utilize DOCS_URL 2024-05-15 15:29:31 +00:00
Ali BARIN
28f39b5c7e Merge pull request #1555 from automatisch/AUT-604
feat(airtable): add find record action
2024-05-15 17:28:38 +02:00
Rıdvan Akca
ec8ac17f4a feat(airtable): add find record action 2024-05-15 15:25:55 +00:00
Ali BARIN
c45573349a Merge pull request #1554 from automatisch/AUT-602
feat(airtable): add create record action
2024-05-15 17:24:58 +02:00
Rıdvan Akca
d36c9d43f6 feat(airtable): add create record action 2024-05-15 17:20:47 +02:00
Rıdvan Akca
b06c744392 feat: implement propTypes for FlowSubstep and FlowSubstepTitle 2024-05-15 13:56:57 +02:00
Ali BARIN
1dc9646894 fix(datastore): make value column text 2024-05-09 20:31:47 +00:00
Rıdvan Akca
c413ab030b feat(airtable): add airtable integration 2024-01-19 17:20:56 +03:00
271 changed files with 5641 additions and 712 deletions

View File

@@ -71,9 +71,6 @@ jobs:
- name: Migrate database
working-directory: ./packages/backend
run: yarn db:migrate
- name: Seed user
working-directory: ./packages/backend
run: yarn db:seed:user &
- name: Install certutils
run: sudo apt install -y libnss3-tools
- name: Install mkcert

View File

@@ -0,0 +1,92 @@
import defineAction from '../../../../helpers/define-action.js';
export default defineAction({
name: 'Create record',
key: 'createRecord',
description: 'Creates a new record with fields that automatically populate.',
arguments: [
{
label: 'Base',
key: 'baseId',
type: 'dropdown',
required: true,
description: 'Base in which to create the record.',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listBases',
},
],
},
},
{
label: 'Table',
key: 'tableId',
type: 'dropdown',
required: true,
dependsOn: ['parameters.baseId'],
description: '',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listTables',
},
{
name: 'parameters.baseId',
value: '{parameters.baseId}',
},
],
},
additionalFields: {
type: 'query',
name: 'getDynamicFields',
arguments: [
{
name: 'key',
value: 'listFields',
},
{
name: 'parameters.baseId',
value: '{parameters.baseId}',
},
{
name: 'parameters.tableId',
value: '{parameters.tableId}',
},
],
},
},
],
async run($) {
const { baseId, tableId, ...rest } = $.step.parameters;
const fields = Object.entries(rest).reduce((result, [key, value]) => {
if (Array.isArray(value)) {
result[key] = value.map((item) => item.value);
} else if (value !== '') {
result[key] = value;
}
return result;
}, {});
const body = {
typecast: true,
fields,
};
const { data } = await $.http.post(`/v0/${baseId}/${tableId}`, body);
$.setActionItem({
raw: data,
});
},
});

View File

@@ -0,0 +1,174 @@
import defineAction from '../../../../helpers/define-action.js';
import { URLSearchParams } from 'url';
export default defineAction({
name: 'Find record',
key: 'findRecord',
description:
"Finds a record using simple field search or use Airtable's formula syntax to find a matching record.",
arguments: [
{
label: 'Base',
key: 'baseId',
type: 'dropdown',
required: true,
description: 'Base in which to create the record.',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listBases',
},
],
},
},
{
label: 'Table',
key: 'tableId',
type: 'dropdown',
required: true,
dependsOn: ['parameters.baseId'],
description: '',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listTables',
},
{
name: 'parameters.baseId',
value: '{parameters.baseId}',
},
],
},
},
{
label: 'Search by field',
key: 'tableField',
type: 'dropdown',
required: false,
dependsOn: ['parameters.baseId', 'parameters.tableId'],
description: '',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listTableFields',
},
{
name: 'parameters.baseId',
value: '{parameters.baseId}',
},
{
name: 'parameters.tableId',
value: '{parameters.tableId}',
},
],
},
},
{
label: 'Search Value',
key: 'searchValue',
type: 'string',
required: false,
variables: true,
description:
'The value of unique identifier for the record. For date values, please use the ISO format (e.g., "YYYY-MM-DD").',
},
{
label: 'Search for exact match?',
key: 'exactMatch',
type: 'dropdown',
required: true,
description: '',
variables: true,
options: [
{ label: 'Yes', value: 'true' },
{ label: 'No', value: 'false' },
],
},
{
label: 'Search Formula',
key: 'searchFormula',
type: 'string',
required: false,
variables: true,
description:
'Instead, you have the option to use an Airtable search formula for locating records according to sophisticated criteria and across various fields.',
},
{
label: 'Limit to View',
key: 'limitToView',
type: 'dropdown',
required: false,
dependsOn: ['parameters.baseId', 'parameters.tableId'],
description:
'You have the choice to restrict the search to a particular view ID if desired.',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listTableViews',
},
{
name: 'parameters.baseId',
value: '{parameters.baseId}',
},
{
name: 'parameters.tableId',
value: '{parameters.tableId}',
},
],
},
},
],
async run($) {
const {
baseId,
tableId,
tableField,
searchValue,
exactMatch,
searchFormula,
limitToView,
} = $.step.parameters;
let filterByFormula;
if (tableField && searchValue) {
filterByFormula =
exactMatch === 'true'
? `{${tableField}} = '${searchValue}'`
: `LOWER({${tableField}}) = LOWER('${searchValue}')`;
} else {
filterByFormula = searchFormula;
}
const body = new URLSearchParams({
filterByFormula,
view: limitToView,
});
const { data } = await $.http.post(
`/v0/${baseId}/${tableId}/listRecords`,
body
);
$.setActionItem({
raw: data,
});
},
});

View File

@@ -0,0 +1,4 @@
import createRecord from './create-record/index.js';
import findRecord from './find-record/index.js';
export default [createRecord, findRecord];

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="256px" height="215px" viewBox="0 0 256 215" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<path d="M114.25873,2.70101695 L18.8604023,42.1756384 C13.5552723,44.3711638 13.6102328,51.9065311 18.9486282,54.0225085 L114.746142,92.0117514 C123.163769,95.3498757 132.537419,95.3498757 140.9536,92.0117514 L236.75256,54.0225085 C242.08951,51.9065311 242.145916,44.3711638 236.83934,42.1756384 L141.442459,2.70101695 C132.738459,-0.900338983 122.961284,-0.900338983 114.25873,2.70101695" fill="#FFBF00"></path>
<path d="M136.349071,112.756863 L136.349071,207.659101 C136.349071,212.173089 140.900664,215.263892 145.096461,213.600615 L251.844122,172.166219 C254.281184,171.200072 255.879376,168.845451 255.879376,166.224705 L255.879376,71.3224678 C255.879376,66.8084791 251.327783,63.7176768 247.131986,65.3809537 L140.384325,106.815349 C137.94871,107.781496 136.349071,110.136118 136.349071,112.756863" fill="#26B5F8"></path>
<path d="M111.422771,117.65355 L79.742409,132.949912 L76.5257763,134.504714 L9.65047684,166.548104 C5.4112904,168.593211 0.000578531073,165.503855 0.000578531073,160.794612 L0.000578531073,71.7210757 C0.000578531073,70.0173017 0.874160452,68.5463864 2.04568588,67.4384994 C2.53454463,66.9481944 3.08848814,66.5446689 3.66412655,66.2250305 C5.26231864,65.2661153 7.54173107,65.0101153 9.47981017,65.7766689 L110.890522,105.957098 C116.045234,108.002206 116.450206,115.225166 111.422771,117.65355" fill="#ED3049"></path>
<path d="M111.422771,117.65355 L79.742409,132.949912 L2.04568588,67.4384994 C2.53454463,66.9481944 3.08848814,66.5446689 3.66412655,66.2250305 C5.26231864,65.2661153 7.54173107,65.0101153 9.47981017,65.7766689 L110.890522,105.957098 C116.045234,108.002206 116.450206,115.225166 111.422771,117.65355" fill-opacity="0.25" fill="#000000"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,38 @@
import crypto from 'crypto';
import { URLSearchParams } from 'url';
import authScope from '../common/auth-scope.js';
export default async function generateAuthUrl($) {
const oauthRedirectUrlField = $.app.auth.fields.find(
(field) => field.key == 'oAuthRedirectUrl'
);
const redirectUri = oauthRedirectUrlField.value;
const state = crypto.randomBytes(100).toString('base64url');
const codeVerifier = crypto.randomBytes(96).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
const searchParams = new URLSearchParams({
client_id: $.auth.data.clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: authScope.join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
const url = `https://airtable.com/oauth2/v1/authorize?${searchParams.toString()}`;
await $.auth.set({
url,
originalCodeChallenge: codeChallenge,
originalState: state,
codeVerifier,
});
}

View File

@@ -0,0 +1,48 @@
import generateAuthUrl from './generate-auth-url.js';
import verifyCredentials from './verify-credentials.js';
import refreshToken from './refresh-token.js';
import isStillVerified from './is-still-verified.js';
export default {
fields: [
{
key: 'oAuthRedirectUrl',
label: 'OAuth Redirect URL',
type: 'string',
required: true,
readOnly: true,
value: '{WEB_APP_URL}/app/airtable/connections/add',
placeholder: null,
description:
'When asked to input a redirect URL in Airtable, enter the URL above.',
clickToCopy: true,
},
{
key: 'clientId',
label: 'Client ID',
type: 'string',
required: true,
readOnly: false,
value: null,
placeholder: null,
description: null,
clickToCopy: false,
},
{
key: 'clientSecret',
label: 'Client Secret',
type: 'string',
required: true,
readOnly: false,
value: null,
placeholder: null,
description: null,
clickToCopy: false,
},
],
generateAuthUrl,
verifyCredentials,
isStillVerified,
refreshToken,
};

View File

@@ -0,0 +1,8 @@
import getCurrentUser from '../common/get-current-user.js';
const isStillVerified = async ($) => {
const currentUser = await getCurrentUser($);
return !!currentUser.id;
};
export default isStillVerified;

View File

@@ -0,0 +1,40 @@
import { URLSearchParams } from 'node:url';
import authScope from '../common/auth-scope.js';
const refreshToken = async ($) => {
const params = new URLSearchParams({
client_id: $.auth.data.clientId,
grant_type: 'refresh_token',
refresh_token: $.auth.data.refreshToken,
});
const basicAuthToken = Buffer.from(
$.auth.data.clientId + ':' + $.auth.data.clientSecret
).toString('base64');
const { data } = await $.http.post(
'https://airtable.com/oauth2/v1/token',
params.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${basicAuthToken}`,
},
additionalProperties: {
skipAddingAuthHeader: true,
},
}
);
await $.auth.set({
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
refreshExpiresIn: data.refresh_expires_in,
scope: authScope.join(' '),
tokenType: data.token_type,
});
};
export default refreshToken;

View File

@@ -0,0 +1,56 @@
import getCurrentUser from '../common/get-current-user.js';
const verifyCredentials = async ($) => {
if ($.auth.data.originalState !== $.auth.data.state) {
throw new Error("The 'state' parameter does not match.");
}
if ($.auth.data.originalCodeChallenge !== $.auth.data.code_challenge) {
throw new Error("The 'code challenge' parameter does not match.");
}
const oauthRedirectUrlField = $.app.auth.fields.find(
(field) => field.key == 'oAuthRedirectUrl'
);
const redirectUri = oauthRedirectUrlField.value;
const basicAuthToken = Buffer.from(
$.auth.data.clientId + ':' + $.auth.data.clientSecret
).toString('base64');
const { data } = await $.http.post(
'https://airtable.com/oauth2/v1/token',
{
code: $.auth.data.code,
client_id: $.auth.data.clientId,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
code_verifier: $.auth.data.codeVerifier,
},
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${basicAuthToken}`,
},
additionalProperties: {
skipAddingAuthHeader: true,
},
}
);
await $.auth.set({
accessToken: data.access_token,
tokenType: data.token_type,
});
const currentUser = await getCurrentUser($);
await $.auth.set({
clientId: $.auth.data.clientId,
clientSecret: $.auth.data.clientSecret,
scope: $.auth.data.scope,
expiresIn: data.expires_in,
refreshExpiresIn: data.refresh_expires_in,
refreshToken: data.refresh_token,
screenName: currentUser.email,
});
};
export default verifyCredentials;

View File

@@ -0,0 +1,12 @@
const addAuthHeader = ($, requestConfig) => {
if (
!requestConfig.additionalProperties?.skipAddingAuthHeader &&
$.auth.data?.accessToken
) {
requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`;
}
return requestConfig;
};
export default addAuthHeader;

View File

@@ -0,0 +1,12 @@
const authScope = [
'data.records:read',
'data.records:write',
'data.recordComments:read',
'data.recordComments:write',
'schema.bases:read',
'schema.bases:write',
'user.email:read',
'webhook:manage',
];
export default authScope;

View File

@@ -0,0 +1,6 @@
const getCurrentUser = async ($) => {
const { data: currentUser } = await $.http.get('/v0/meta/whoami');
return currentUser;
};
export default getCurrentUser;

View File

@@ -0,0 +1,6 @@
import listBases from './list-bases/index.js';
import listTableFields from './list-table-fields/index.js';
import listTableViews from './list-table-views/index.js';
import listTables from './list-tables/index.js';
export default [listBases, listTableFields, listTableViews, listTables];

View File

@@ -0,0 +1,28 @@
export default {
name: 'List bases',
key: 'listBases',
async run($) {
const bases = {
data: [],
};
const params = {};
do {
const { data } = await $.http.get('/v0/meta/bases', { params });
params.offset = data.offset;
if (data?.bases) {
for (const base of data.bases) {
bases.data.push({
value: base.id,
name: base.name,
});
}
}
} while (params.offset);
return bases;
},
};

View File

@@ -0,0 +1,39 @@
export default {
name: 'List table fields',
key: 'listTableFields',
async run($) {
const tableFields = {
data: [],
};
const { baseId, tableId } = $.step.parameters;
if (!baseId) {
return tableFields;
}
const params = {};
do {
const { data } = await $.http.get(`/v0/meta/bases/${baseId}/tables`, {
params,
});
params.offset = data.offset;
if (data?.tables) {
for (const table of data.tables) {
if (table.id === tableId) {
table.fields.forEach((field) => {
tableFields.data.push({
value: field.name,
name: field.name,
});
});
}
}
}
} while (params.offset);
return tableFields;
},
};

View File

@@ -0,0 +1,39 @@
export default {
name: 'List table views',
key: 'listTableViews',
async run($) {
const tableViews = {
data: [],
};
const { baseId, tableId } = $.step.parameters;
if (!baseId) {
return tableViews;
}
const params = {};
do {
const { data } = await $.http.get(`/v0/meta/bases/${baseId}/tables`, {
params,
});
params.offset = data.offset;
if (data?.tables) {
for (const table of data.tables) {
if (table.id === tableId) {
table.views.forEach((view) => {
tableViews.data.push({
value: view.id,
name: view.name,
});
});
}
}
}
} while (params.offset);
return tableViews;
},
};

View File

@@ -0,0 +1,35 @@
export default {
name: 'List tables',
key: 'listTables',
async run($) {
const tables = {
data: [],
};
const baseId = $.step.parameters.baseId;
if (!baseId) {
return tables;
}
const params = {};
do {
const { data } = await $.http.get(`/v0/meta/bases/${baseId}/tables`, {
params,
});
params.offset = data.offset;
if (data?.tables) {
for (const table of data.tables) {
tables.data.push({
value: table.id,
name: table.name,
});
}
}
} while (params.offset);
return tables;
},
};

View File

@@ -0,0 +1,3 @@
import listFields from './list-fields/index.js';
export default [listFields];

View File

@@ -0,0 +1,86 @@
const hasValue = (value) => value !== null && value !== undefined;
export default {
name: 'List fields',
key: 'listFields',
async run($) {
const options = [];
const { baseId, tableId } = $.step.parameters;
if (!hasValue(baseId) || !hasValue(tableId)) {
return;
}
const { data } = await $.http.get(`/v0/meta/bases/${baseId}/tables`);
const selectedTable = data.tables.find((table) => table.id === tableId);
if (!selectedTable) return;
selectedTable.fields.forEach((field) => {
if (field.type === 'singleSelect') {
options.push({
label: field.name,
key: field.name,
type: 'dropdown',
required: false,
variables: true,
options: field.options.choices.map((choice) => ({
label: choice.name,
value: choice.id,
})),
});
} else if (field.type === 'multipleSelects') {
options.push({
label: field.name,
key: field.name,
type: 'dynamic',
required: false,
variables: true,
fields: [
{
label: 'Value',
key: 'value',
type: 'dropdown',
required: false,
variables: true,
options: field.options.choices.map((choice) => ({
label: choice.name,
value: choice.id,
})),
},
],
});
} else if (field.type === 'checkbox') {
options.push({
label: field.name,
key: field.name,
type: 'dropdown',
required: false,
variables: true,
options: [
{
label: 'Yes',
value: 'true',
},
{
label: 'No',
value: 'false',
},
],
});
} else {
options.push({
label: field.name,
key: field.name,
type: 'string',
required: false,
variables: true,
});
}
});
return options;
},
};

View File

@@ -0,0 +1,22 @@
import defineApp from '../../helpers/define-app.js';
import addAuthHeader from './common/add-auth-header.js';
import auth from './auth/index.js';
import actions from './actions/index.js';
import dynamicData from './dynamic-data/index.js';
import dynamicFields from './dynamic-fields/index.js';
export default defineApp({
name: 'Airtable',
key: 'airtable',
baseUrl: 'https://airtable.com',
apiBaseUrl: 'https://api.airtable.com',
iconUrl: '{BASE_URL}/apps/airtable/assets/favicon.svg',
authDocUrl: '{DOCS_URL}/apps/airtable/connection',
primaryColor: 'FFBF00',
supportsConnections: true,
beforeRequest: [addAuthHeader],
auth,
actions,
dynamicData,
dynamicFields,
});

View File

@@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="132" height="24" fill="none" viewBox="0 0 132 24"><path fill="#19191C" d="M38.557 19.495c2.16 0 3.25-1.113 3.725-1.87h.214c.094.805.664 1.562 1.779 1.562h2.111V16.82h-.545c-.38 0-.57-.213-.57-.545V6.776h-2.8v1.516h-.213c-.545-.758-1.684-1.824-3.772-1.824-3.321 0-5.789 2.748-5.789 6.514s2.515 6.513 5.86 6.513m.498-2.7c-1.969 0-3.51-1.445-3.51-3.79 0-2.297 1.494-3.86 3.487-3.86 1.898 0 3.487 1.397 3.487 3.86 0 2.108-1.352 3.79-3.463 3.79M48.04 24h2.799v-6.376h.213c.522.758 1.637 1.871 3.844 1.871 3.321 0 5.741-2.795 5.741-6.513 0-3.743-2.586-6.514-5.931-6.514-2.135 0-3.18 1.16-3.678 1.8h-.213V6.776h-2.776V24m6.263-7.134c-1.922 0-3.512-1.42-3.512-3.884 0-2.108 1.353-3.885 3.464-3.885 1.97 0 3.511 1.54 3.511 3.885 0 2.297-1.494 3.884-3.463 3.884M62.082 24h2.8v-6.376h.213c.522.758 1.637 1.871 3.843 1.871 3.321 0 5.51-2.795 5.51-6.513 0-3.743-2.355-6.514-5.7-6.514-2.135 0-3.179 1.16-3.677 1.8h-.214V6.776h-2.775zm6.263-7.134c-1.922 0-3.511-1.42-3.511-3.884 0-2.108 1.352-3.885 3.463-3.885 1.97 0 3.512 1.54 3.512 3.885 0 2.297-1.495 3.884-3.464 3.884m9.805 2.61h3.961l2.254-9.735h.143l2.253 9.735H90.7l3.153-12.412h-2.821l-2.254 9.759h-.214l-2.253-9.759h-3.725l-2.278 9.759h-.213l-2.23-9.759h-2.99l3.274 12.412m17.123 0h2.8V13.34c0-2.345 1.09-3.79 3.131-3.79h1.233V6.756h-.925c-1.59 0-2.8 1.09-3.274 2.132h-.19V7.064h-2.775zm21.057 0h2.183v-2.487h-2.159c-.854 0-1.21-.38-1.21-1.256V9.528h3.511V7.064h-3.511V3.582h-2.657v3.482h-2.325v2.464h2.159v6.229c0 2.63 1.589 3.719 4.009 3.719m9.693.019c2.586 0 4.864-1.279 5.67-3.86l-2.562-.616c-.451 1.373-1.755 2.084-3.131 2.084-2.041 0-3.393-1.326-3.417-3.41h9.419v-.782c0-3.695-2.301-6.443-6.097-6.443-3.346 0-6.216 2.63-6.216 6.537 0 3.79 2.538 6.49 6.334 6.49m-3.416-7.84c.166-1.492 1.518-2.747 3.298-2.747 1.708 0 3.108 1.066 3.25 2.747h-6.548"/><path fill="#19191C" fill-rule="evenodd" d="M108.916 19.476h-2.8V9.528h-2.182V7.064h4.982z" clip-rule="evenodd"/><path fill="#19191C" d="M107.309 5.342c1.02 0 1.779-.758 1.779-1.753 0-.971-.759-1.73-1.779-1.73-1.021 0-1.78.759-1.78 1.73 0 .995.759 1.753 1.78 1.753"/><path fill="#FD366E" d="M24.443 16.432v5.478H10.752c-3.989 0-7.472-2.203-9.335-5.478A11.041 11.041 0 0 1 0 11.695v-1.48a10.97 10.97 0 0 1 .381-2.247C1.661 3.368 5.82 0 10.751 0c4.934 0 9.092 3.37 10.371 7.967h-5.854c-.96-1.499-2.624-2.49-4.516-2.49s-3.555.991-4.516 2.49a5.47 5.47 0 0 0-.67 1.494 5.562 5.562 0 0 0-.202 1.494 5.5 5.5 0 0 0 1.69 3.983 5.32 5.32 0 0 0 3.698 1.494h13.69"/><path fill="#FD366E" d="M24.443 9.46v5.478h-9.994a5.5 5.5 0 0 0 1.691-3.983 5.56 5.56 0 0 0-.203-1.494h8.506"/></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,65 @@
import verifyCredentials from './verify-credentials.js';
import isStillVerified from './is-still-verified.js';
export default {
fields: [
{
key: 'screenName',
label: 'Screen Name',
type: 'string',
required: true,
readOnly: false,
value: null,
placeholder: null,
description:
'Screen name of your connection to be used on Automatisch UI.',
clickToCopy: false,
},
{
key: 'projectId',
label: 'Project ID',
type: 'string',
required: true,
readOnly: false,
value: null,
placeholder: null,
description: 'Project ID of your Appwrite project.',
clickToCopy: false,
},
{
key: 'apiKey',
label: 'API Key',
type: 'string',
required: true,
readOnly: false,
value: null,
placeholder: null,
description: 'API key of your Appwrite project.',
clickToCopy: false,
},
{
key: 'instanceUrl',
label: 'Appwrite instance URL',
type: 'string',
required: false,
readOnly: false,
placeholder: '',
description: '',
clickToCopy: true,
},
{
key: 'host',
label: 'Host Name',
type: 'string',
required: true,
readOnly: false,
value: null,
placeholder: null,
description: 'Host name of your Appwrite project.',
clickToCopy: false,
},
],
verifyCredentials,
isStillVerified,
};

View File

@@ -0,0 +1,8 @@
import verifyCredentials from './verify-credentials.js';
const isStillVerified = async ($) => {
await verifyCredentials($);
return true;
};
export default isStillVerified;

View File

@@ -0,0 +1,5 @@
const verifyCredentials = async ($) => {
await $.http.get('/v1/users');
};
export default verifyCredentials;

View File

@@ -0,0 +1,16 @@
const addAuthHeader = ($, requestConfig) => {
requestConfig.headers['Content-Type'] = 'application/json';
if ($.auth.data?.apiKey && $.auth.data?.projectId) {
requestConfig.headers['X-Appwrite-Project'] = $.auth.data.projectId;
requestConfig.headers['X-Appwrite-Key'] = $.auth.data.apiKey;
}
if ($.auth.data?.host) {
requestConfig.headers['Host'] = $.auth.data.host;
}
return requestConfig;
};
export default addAuthHeader;

View File

@@ -0,0 +1,13 @@
const setBaseUrl = ($, requestConfig) => {
const instanceUrl = $.auth.data.instanceUrl;
if (instanceUrl) {
requestConfig.baseURL = instanceUrl;
} else if ($.app.apiBaseUrl) {
requestConfig.baseURL = $.app.apiBaseUrl;
}
return requestConfig;
};
export default setBaseUrl;

View File

@@ -0,0 +1,4 @@
import listCollections from './list-collections/index.js';
import listDatabases from './list-databases/index.js';
export default [listCollections, listDatabases];

View File

@@ -0,0 +1,44 @@
export default {
name: 'List collections',
key: 'listCollections',
async run($) {
const collections = {
data: [],
};
const databaseId = $.step.parameters.databaseId;
if (!databaseId) {
return collections;
}
const params = {
queries: [
JSON.stringify({
method: 'orderAsc',
attribute: 'name',
}),
JSON.stringify({
method: 'limit',
values: [100],
}),
],
};
const { data } = await $.http.get(
`/v1/databases/${databaseId}/collections`,
{ params }
);
if (data?.collections) {
for (const collection of data.collections) {
collections.data.push({
value: collection.$id,
name: collection.name,
});
}
}
return collections;
},
};

View File

@@ -0,0 +1,36 @@
export default {
name: 'List databases',
key: 'listDatabases',
async run($) {
const databases = {
data: [],
};
const params = {
queries: [
JSON.stringify({
method: 'orderAsc',
attribute: 'name',
}),
JSON.stringify({
method: 'limit',
values: [100],
}),
],
};
const { data } = await $.http.get('/v1/databases', { params });
if (data?.databases) {
for (const database of data.databases) {
databases.data.push({
value: database.$id,
name: database.name,
});
}
}
return databases;
},
};

View File

@@ -0,0 +1,21 @@
import defineApp from '../../helpers/define-app.js';
import addAuthHeader from './common/add-auth-header.js';
import setBaseUrl from './common/set-base-url.js';
import auth from './auth/index.js';
import triggers from './triggers/index.js';
import dynamicData from './dynamic-data/index.js';
export default defineApp({
name: 'Appwrite',
key: 'appwrite',
baseUrl: 'https://appwrite.io',
apiBaseUrl: 'https://cloud.appwrite.io',
iconUrl: '{BASE_URL}/apps/appwrite/assets/favicon.svg',
authDocUrl: '{DOCS_URL}/apps/appwrite/connection',
primaryColor: 'FD366E',
supportsConnections: true,
beforeRequest: [setBaseUrl, addAuthHeader],
auth,
triggers,
dynamicData,
});

View File

@@ -0,0 +1,3 @@
import newDocuments from './new-documents/index.js';
export default [newDocuments];

View File

@@ -0,0 +1,104 @@
import defineTrigger from '../../../../helpers/define-trigger.js';
export default defineTrigger({
name: 'New documents',
key: 'newDocuments',
pollInterval: 15,
description: 'Triggers when a new document is created.',
arguments: [
{
label: 'Database',
key: 'databaseId',
type: 'dropdown',
required: true,
description: '',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listDatabases',
},
],
},
},
{
label: 'Collection',
key: 'collectionId',
type: 'dropdown',
required: true,
dependsOn: ['parameters.databaseId'],
description: '',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listCollections',
},
{
name: 'parameters.databaseId',
value: '{parameters.databaseId}',
},
],
},
},
],
async run($) {
const { databaseId, collectionId } = $.step.parameters;
const limit = 1;
let lastDocumentId = undefined;
let offset = 0;
let documentCount = 0;
do {
const params = {
queries: [
JSON.stringify({
method: 'orderDesc',
attribute: '$createdAt',
}),
JSON.stringify({
method: 'limit',
values: [limit],
}),
// An invalid cursor shouldn't be sent.
lastDocumentId &&
JSON.stringify({
method: 'cursorAfter',
values: [lastDocumentId],
}),
].filter(Boolean),
};
const { data } = await $.http.get(
`/v1/databases/${databaseId}/collections/${collectionId}/documents`,
{ params }
);
const documents = data?.documents;
documentCount = documents?.length;
offset = offset + limit;
lastDocumentId = documents[documentCount - 1]?.$id;
if (!documentCount) {
return;
}
for (const document of documents) {
$.pushTriggerItem({
raw: document,
meta: {
internalId: document.$id,
},
});
}
} while (documentCount === limit);
},
});

View File

@@ -0,0 +1,64 @@
import { createHmac } from 'node:crypto';
import defineAction from '../../../../helpers/define-action.js';
export default defineAction({
name: 'Create HMAC',
key: 'createHmac',
description: 'Create a Hash-based Message Authentication Code (HMAC) using the specified algorithm, secret key, and message.',
arguments: [
{
label: 'Algorithm',
key: 'algorithm',
type: 'dropdown',
required: true,
value: 'sha256',
description: 'Specifies the cryptographic hash function to use for HMAC generation.',
options: [
{ label: 'SHA-256', value: 'sha256' },
],
variables: true,
},
{
label: 'Message',
key: 'message',
type: 'string',
required: true,
description: 'The input message to be hashed. This is the value that will be processed to generate the HMAC.',
variables: true,
},
{
label: 'Secret Key',
key: 'secretKey',
type: 'string',
required: true,
description: 'The secret key used to create the HMAC.',
variables: true,
},
{
label: 'Output Encoding',
key: 'outputEncoding',
type: 'dropdown',
required: true,
value: 'hex',
description: 'Specifies the encoding format for the HMAC digest output.',
options: [
{ label: 'base64', value: 'base64' },
{ label: 'base64url', value: 'base64url' },
{ label: 'hex', value: 'hex' },
],
variables: true,
},
],
async run($) {
const hash = createHmac($.step.parameters.algorithm, $.step.parameters.secretKey)
.update($.step.parameters.message)
.digest($.step.parameters.outputEncoding);
$.setActionItem({
raw: {
hash
},
});
},
});

View File

@@ -0,0 +1,65 @@
import crypto from 'node:crypto';
import defineAction from '../../../../helpers/define-action.js';
export default defineAction({
name: 'Create Signature',
key: 'createSignature',
description: 'Create a digital signature using the specified algorithm, secret key, and message.',
arguments: [
{
label: 'Algorithm',
key: 'algorithm',
type: 'dropdown',
required: true,
value: 'RSA-SHA256',
description: 'Specifies the cryptographic hash function to use for HMAC generation.',
options: [
{ label: 'RSA-SHA256', value: 'RSA-SHA256' },
],
variables: true,
},
{
label: 'Message',
key: 'message',
type: 'string',
required: true,
description: 'The input message to be signed.',
variables: true,
},
{
label: 'Private Key',
key: 'privateKey',
type: 'string',
required: true,
description: 'The RSA private key in PEM format used for signing.',
variables: true,
},
{
label: 'Output Encoding',
key: 'outputEncoding',
type: 'dropdown',
required: true,
value: 'hex',
description: 'Specifies the encoding format for the digital signature output. This determines how the generated signature will be represented as a string.',
options: [
{ label: 'base64', value: 'base64' },
{ label: 'base64url', value: 'base64url' },
{ label: 'hex', value: 'hex' },
],
variables: true,
},
],
async run($) {
const signer = crypto.createSign($.step.parameters.algorithm);
signer.update($.step.parameters.message);
signer.end();
const signature = signer.sign($.step.parameters.privateKey, $.step.parameters.outputEncoding);
$.setActionItem({
raw: {
signature
},
});
},
});

View File

@@ -0,0 +1,4 @@
import createHmac from './create-hmac/index.js';
import createRsaSha256Signature from './create-rsa-sha256-signature/index.js';
export default [createHmac, createRsaSha256Signature];

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100pt" height="100pt" version="1.1" viewBox="0 0 100 100">
<path d="m66.012 33h-3.0117v-11c0-7.1719-5.8281-13-13-13s-13 5.8281-13 13v11h-3.0117c-2.75 0-4.9883 2.2383-4.9883 4.9883v28.012c0 2.75 2.2383 4.9883 4.9883 4.9883h32.012c2.75 0 4.9883-2.2383 4.9883-4.9883v-28.012c0.011719-2.75-2.2266-4.9883-4.9766-4.9883zm-27.012-11c0-6.0703 4.9297-11 11-11s11 4.9297 11 11v11h-22zm30 44.012c0 1.6484-1.3398 2.9883-2.9883 2.9883h-32.023c-1.6484 0-2.9883-1.3398-2.9883-2.9883v-28.023c0-1.6484 1.3398-2.9883 2.9883-2.9883h32.023c1.6484 0 2.9883 1.3398 2.9883 2.9883zm-18 9.9883v14c0 0.55078-0.44922 1-1 1s-1-0.44922-1-1v-14c0-0.55078 0.44922-1 1-1s1 0.44922 1 1zm20 8c0 0.55078-0.44922 1-1 1h-8c-0.55078 0-1-0.44922-1-1v-8c0-0.55078 0.44922-1 1-1s1 0.44922 1 1v7h7c0.55078 0 1 0.44922 1 1zm-32-8v8c0 0.55078-0.44922 1-1 1h-8c-0.55078 0-1-0.44922-1-1s0.44922-1 1-1h7v-7c0-0.55078 0.44922-1 1-1s1 0.44922 1 1zm-14-26c0 0.55078-0.44922 1-1 1h-14c-0.55078 0-1-0.44922-1-1s0.44922-1 1-1h14c0.55078 0 1 0.44922 1 1zm0-12c0 0.55078-0.44922 1-1 1h-8c-0.55078 0-1-0.44922-1-1v-8c0-0.55078 0.44922-1 1-1s1 0.44922 1 1v7h7c0.55078 0 1 0.44922 1 1zm0 24c0 0.55078-0.44922 1-1 1h-7v7c0 0.55078-0.44922 1-1 1s-1-0.44922-1-1v-8c0-0.55078 0.44922-1 1-1h8c0.55078 0 1 0.44922 1 1zm66-12c0 0.55078-0.44922 1-1 1h-14c-0.55078 0-1-0.44922-1-1s0.44922-1 1-1h14c0.55078 0 1 0.44922 1 1zm-16-12c0-0.55078 0.44922-1 1-1h7v-7c0-0.55078 0.44922-1 1-1s1 0.44922 1 1v8c0 0.55078-0.44922 1-1 1h-8c-0.55078 0-1-0.44922-1-1zm10 24v8c0 0.55078-0.44922 1-1 1s-1-0.44922-1-1v-7h-7c-0.55078 0-1-0.44922-1-1s0.44922-1 1-1h8c0.55078 0 1 0.44922 1 1zm-35-17c-2.7617 0-5 2.2383-5 5 0 2.4102 1.7188 4.4297 4 4.8984v5.1016c0 0.55078 0.44922 1 1 1s1-0.44922 1-1v-5.1016c2.2812-0.46094 4-2.4805 4-4.8984 0-2.7617-2.2383-5-5-5zm0 8c-1.6484 0-3-1.3516-3-3s1.3516-3 3-3 3 1.3516 3 3-1.3516 3-3 3z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,14 @@
import defineApp from '../../helpers/define-app.js';
import actions from './actions/index.js';
export default defineApp({
name: 'Cryptography',
key: 'cryptography',
iconUrl: '{BASE_URL}/apps/cryptography/assets/favicon.svg',
authDocUrl: '{DOCS_URL}/apps/cryptography/connection',
supportsConnections: false,
baseUrl: '',
apiBaseUrl: '',
primaryColor: '001F52',
actions,
});

View File

@@ -1,8 +1,10 @@
import defineAction from '../../../../helpers/define-action.js';
import formatDateTime from './transformers/format-date-time.js';
import getCurrentTimestamp from './transformers/get-current-timestamp.js';
const transformers = {
formatDateTime,
getCurrentTimestamp,
};
export default defineAction({
@@ -16,7 +18,16 @@ export default defineAction({
type: 'dropdown',
required: true,
variables: true,
options: [{ label: 'Format Date / Time', value: 'formatDateTime' }],
options: [
{
label: 'Get current timestamp',
value: 'getCurrentTimestamp',
},
{
label: 'Format Date / Time',
value: 'formatDateTime',
},
],
additionalFields: {
type: 'query',
name: 'getDynamicFields',

View File

@@ -5,11 +5,24 @@ const formatDateTime = ($) => {
const fromFormat = $.step.parameters.fromFormat;
const fromTimezone = $.step.parameters.fromTimezone;
let inputDateTime;
const inputDateTime = DateTime.fromFormat(input, fromFormat, {
zone: fromTimezone,
setZone: true,
});
if (fromFormat === 'X') {
inputDateTime = DateTime.fromSeconds(Number(input), fromFormat, {
zone: fromTimezone,
setZone: true,
});
} else if (fromFormat === 'x') {
inputDateTime = DateTime.fromMillis(Number(input), fromFormat, {
zone: fromTimezone,
setZone: true,
});
} else {
inputDateTime = DateTime.fromFormat(input, fromFormat, {
zone: fromTimezone,
setZone: true,
});
}
const toFormat = $.step.parameters.toFormat;
const toTimezone = $.step.parameters.toTimezone;

View File

@@ -0,0 +1,5 @@
const getCurrentTimestamp = () => {
return Date.now();
};
export default getCurrentTimestamp;

View File

@@ -14,6 +14,8 @@ import stringToBase64 from './transformers/string-to-base64.js';
import encodeUri from './transformers/encode-uri.js';
import trimWhitespace from './transformers/trim-whitespace.js';
import useDefaultValue from './transformers/use-default-value.js';
import parseStringifiedJson from './transformers/parse-stringified-json.js';
import createUuid from './transformers/create-uuid.js';
const transformers = {
base64ToString,
@@ -30,6 +32,8 @@ const transformers = {
encodeUri,
trimWhitespace,
useDefaultValue,
parseStringifiedJson,
createUuid,
};
export default defineAction({
@@ -47,19 +51,21 @@ export default defineAction({
options: [
{ label: 'Base64 to String', value: 'base64ToString' },
{ label: 'Capitalize', value: 'capitalize' },
{ label: 'Convert HTML to Markdown', value: 'htmlToMarkdown' },
{ label: 'Convert Markdown to HTML', value: 'markdownToHtml' },
{ label: 'Create UUID', value: 'createUuid' },
{ label: 'Encode URI', value: 'encodeUri' },
{
label: 'Encode URI Component',
value: 'encodeUriComponent',
},
{ label: 'Convert HTML to Markdown', value: 'htmlToMarkdown' },
{ label: 'Convert Markdown to HTML', value: 'markdownToHtml' },
{ label: 'Extract Email Address', value: 'extractEmailAddress' },
{ label: 'Extract Number', value: 'extractNumber' },
{ label: 'Lowercase', value: 'lowercase' },
{ label: 'Parse stringified JSON', value: 'parseStringifiedJson' },
{ label: 'Pluralize', value: 'pluralize' },
{ label: 'Replace', value: 'replace' },
{ label: 'String to Base64', value: 'stringToBase64' },
{ label: 'Encode URI', value: 'encodeUri' },
{ label: 'Trim Whitespace', value: 'trimWhitespace' },
{ label: 'Use Default Value', value: 'useDefaultValue' },
],

View File

@@ -0,0 +1,7 @@
import { v4 as uuidv4 } from 'uuid';
const createUuidV4 = () => {
return uuidv4();
};
export default createUuidV4;

View File

@@ -0,0 +1,7 @@
const parseStringifiedJson = ($) => {
const input = $.step.parameters.input;
return JSON.parse(input);
};
export default parseStringifiedJson;

View File

@@ -1,8 +1,26 @@
const replace = ($) => {
const input = $.step.parameters.input;
const find = $.step.parameters.find;
const replace = $.step.parameters.replace;
const useRegex = $.step.parameters.useRegex;
if (useRegex) {
const ignoreCase = $.step.parameters.ignoreCase;
const flags = [ignoreCase && 'i', 'g'].filter(Boolean).join('');
const timeoutId = setTimeout(() => {
$.execution.exit();
}, 100);
const regex = new RegExp(find, flags);
const replacedValue = input.replaceAll(regex, replace);
clearTimeout(timeoutId);
return replacedValue;
}
return input.replaceAll(find, replace);
};

View File

@@ -1,3 +1,4 @@
import listTransformOptions from './list-transform-options/index.js';
import listReplaceRegexOptions from './list-replace-regex-options/index.js';
export default [listTransformOptions];
export default [listTransformOptions, listReplaceRegexOptions];

View File

@@ -0,0 +1,23 @@
export default {
name: 'List replace regex options',
key: 'listReplaceRegexOptions',
async run($) {
if (!$.step.parameters.useRegex) return [];
return [
{
label: 'Ignore case',
key: 'ignoreCase',
type: 'dropdown',
required: true,
description: 'Ignore case sensitivity.',
variables: true,
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
];
},
};

View File

@@ -12,6 +12,7 @@ import stringToBase64 from './text/string-to-base64.js';
import encodeUri from './text/encode-uri.js';
import trimWhitespace from './text/trim-whitespace.js';
import useDefaultValue from './text/use-default-value.js';
import parseStringifiedJson from './text/parse-stringified-json.js';
import performMathOperation from './numbers/perform-math-operation.js';
import randomNumber from './numbers/random-number.js';
import formatNumber from './numbers/format-number.js';
@@ -38,6 +39,7 @@ const options = {
formatNumber,
formatPhoneNumber,
formatDateTime,
parseStringifiedJson,
};
export default {

View File

@@ -0,0 +1,12 @@
const useDefaultValue = [
{
label: 'Input',
key: 'input',
type: 'string',
required: true,
description: 'Stringified JSON you want to parse.',
variables: true,
},
];
export default useDefaultValue;

View File

@@ -23,6 +23,33 @@ const replace = [
description: 'Text that will replace the found text.',
variables: true,
},
{
label: 'Use Regular Expression',
key: 'useRegex',
type: 'dropdown',
required: true,
description: 'Use regex to search values.',
variables: true,
value: false,
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
additionalFields: {
type: 'query',
name: 'getDynamicFields',
arguments: [
{
name: 'key',
value: 'listReplaceRegexOptions',
},
{
name: 'parameters.useRegex',
value: '{parameters.useRegex}',
},
],
},
},
];
export default replace;

View File

@@ -89,6 +89,8 @@ export default defineAction({
const response = await $.http.post('/', payload);
console.log(response.config.additionalProperties.extraData);
$.setActionItem({
raw: response.data,
});

View File

@@ -1,4 +1,7 @@
const addAuthHeader = ($, requestConfig) => {
console.log('requestConfig', requestConfig)
if (requestConfig.additionalProperties?.skip) return requestConfig;
if ($.auth.data.serverUrl) {
requestConfig.baseURL = $.auth.data.serverUrl;
}

View File

@@ -0,0 +1,23 @@
const asyncBeforeRequest = async ($, requestConfig) => {
if (requestConfig.additionalProperties?.skip)
return requestConfig;
const response = await $.http.post(
'http://localhost:3000/webhooks/flows/8a040f4e-817f-4076-80ba-3c1c0af7e65e/sync',
null,
{
additionalProperties: {
skip: true,
},
}
);
console.log(response);
requestConfig.additionalProperties = {
extraData: response.data
}
return requestConfig;
};
export default asyncBeforeRequest;

View File

@@ -1,5 +1,6 @@
import defineApp from '../../helpers/define-app.js';
import addAuthHeader from './common/add-auth-header.js';
import asyncBeforeRequest from './common/async-before-request.js';
import auth from './auth/index.js';
import actions from './actions/index.js';
@@ -12,7 +13,7 @@ export default defineApp({
baseUrl: 'https://ntfy.sh',
apiBaseUrl: 'https://ntfy.sh',
primaryColor: '56bda8',
beforeRequest: [addAuthHeader],
beforeRequest: [asyncBeforeRequest, addAuthHeader],
auth,
actions,
});

View File

@@ -0,0 +1,101 @@
import defineAction from '../../../../helpers/define-action.js';
import listObjects from '../../dynamic-data/list-objects/index.js';
import listFields from '../../dynamic-data/list-fields/index.js';
export default defineAction({
name: 'Find partially matching record',
key: 'findPartiallyMatchingRecord',
description: 'Finds a record of a specified object by a field containing a value.',
arguments: [
{
label: 'Object',
key: 'object',
type: 'dropdown',
required: true,
variables: true,
description: 'Pick which type of object you want to search for.',
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listObjects',
},
],
},
},
{
label: 'Field',
key: 'field',
type: 'dropdown',
description: 'Pick which field to search by',
required: true,
variables: true,
dependsOn: ['parameters.object'],
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listFields',
},
{
name: 'parameters.object',
value: '{parameters.object}',
},
],
},
},
{
label: 'Search value to contain',
key: 'searchValue',
type: 'string',
required: true,
variables: true,
description: 'The value to search for in the field.',
},
],
async run($) {
const sanitizedSearchValue = $.step.parameters.searchValue.replaceAll(`'`, `\\'`);
// validate given object
const objects = await listObjects.run($);
const validObject = objects.data.find((object) => object.value === $.step.parameters.object);
if (!validObject) {
throw new Error(`The "${$.step.parameters.object}" object does not exist.`);
}
// validate given object field
const fields = await listFields.run($);
const validField = fields.data.find((field) => field.value === $.step.parameters.field);
if (!validField) {
throw new Error(`The "${$.step.parameters.field}" field does not exist on the "${$.step.parameters.object}" object.`);
}
const query = `
SELECT
FIELDS(ALL)
FROM
${$.step.parameters.object}
WHERE
${$.step.parameters.field} LIKE '%${sanitizedSearchValue}%'
LIMIT 1
`;
const options = {
params: {
q: query,
},
};
const { data } = await $.http.get('/services/data/v61.0/query', options);
const record = data.records[0];
$.setActionItem({ raw: record });
},
});

View File

@@ -1,5 +1,6 @@
import createAttachment from './create-attachment/index.js';
import executeQuery from './execute-query/index.js';
import findRecord from './find-record/index.js';
import findPartiallyMatchingRecord from './find-partially-matching-record/index.js';
export default [findRecord, createAttachment, executeQuery];
export default [findRecord, findPartiallyMatchingRecord, createAttachment, executeQuery];

View File

@@ -52,7 +52,7 @@ const appConfig = {
isDev: appEnv === 'development',
isTest: appEnv === 'test',
isProd: appEnv === 'production',
version: '0.11.0',
version: '0.12.0',
postgresDatabase: process.env.POSTGRES_DATABASE || 'automatisch_development',
postgresSchema: process.env.POSTGRES_SCHEMA || 'public',
postgresPort: parseInt(process.env.POSTGRES_PORT || '5432'),
@@ -97,8 +97,12 @@ const appConfig = {
disableNotificationsPage: process.env.DISABLE_NOTIFICATIONS_PAGE === 'true',
disableFavicon: process.env.DISABLE_FAVICON === 'true',
additionalDrawerLink: process.env.ADDITIONAL_DRAWER_LINK,
additionalDrawerLinkIcon: process.env.ADDITIONAL_DRAWER_LINK_ICON,
additionalDrawerLinkText: process.env.ADDITIONAL_DRAWER_LINK_TEXT,
disableSeedUser: process.env.DISABLE_SEED_USER === 'true',
httpProxy: process.env.http_proxy,
httpsProxy: process.env.https_proxy,
noProxy: process.env.no_proxy,
};
if (!appConfig.encryptionKey) {

View File

@@ -0,0 +1,10 @@
import User from '../../../../../models/user.js';
export default async (request, response) => {
const id = request.params.userId;
const user = await User.query().findById(id).throwIfNotFound();
await user.softRemove();
response.status(204).end();
};

View File

@@ -0,0 +1,43 @@
import { describe, it, beforeEach } from 'vitest';
import request from 'supertest';
import Crypto from 'crypto';
import app from '../../../../../app.js';
import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id';
import { createUser } from '../../../../../../test/factories/user';
import { createRole } from '../../../../../../test/factories/role';
describe('DELETE /api/v1/admin/users/:userId', () => {
let currentUser, currentUserRole, anotherUser, token;
beforeEach(async () => {
currentUserRole = await createRole({ key: 'admin' });
currentUser = await createUser({ roleId: currentUserRole.id });
anotherUser = await createUser();
token = await createAuthTokenByUserId(currentUser.id);
});
it('should soft delete user and respond with no content', async () => {
await request(app)
.delete(`/api/v1/admin/users/${anotherUser.id}`)
.set('Authorization', token)
.expect(204);
});
it('should return not found response for not existing user UUID', async () => {
const notExistingUserUUID = Crypto.randomUUID();
await request(app)
.delete(`/api/v1/admin/users/${notExistingUserUUID}`)
.set('Authorization', token)
.expect(404);
});
it('should return bad request response for invalid UUID', async () => {
await request(app)
.delete('/api/v1/admin/users/invalidUserUUID')
.set('Authorization', token)
.expect(400);
});
});

View File

@@ -7,6 +7,7 @@ export default async (request, response) => {
disableNotificationsPage: appConfig.disableNotificationsPage,
disableFavicon: appConfig.disableFavicon,
additionalDrawerLink: appConfig.additionalDrawerLink,
additionalDrawerLinkIcon: appConfig.additionalDrawerLinkIcon,
additionalDrawerLinkText: appConfig.additionalDrawerLinkText,
};

View File

@@ -4,6 +4,7 @@ import { createConfig } from '../../../../../test/factories/config.js';
import app from '../../../../app.js';
import configMock from '../../../../../test/mocks/rest/api/v1/automatisch/config.js';
import * as license from '../../../../helpers/license.ee.js';
import appConfig from '../../../../config/app.js';
describe('GET /api/v1/automatisch/config', () => {
it('should return Automatisch config', async () => {
@@ -48,4 +49,18 @@ describe('GET /api/v1/automatisch/config', () => {
expect(response.body).toEqual(expectedPayload);
});
it('should return additional environment variables', async () => {
vi.spyOn(appConfig, 'disableNotificationsPage', 'get').mockReturnValue(true);
vi.spyOn(appConfig, 'disableFavicon', 'get').mockReturnValue(true);
vi.spyOn(appConfig, 'additionalDrawerLink', 'get').mockReturnValue('link');
vi.spyOn(appConfig, 'additionalDrawerLinkIcon', 'get').mockReturnValue('icon');
vi.spyOn(appConfig, 'additionalDrawerLinkText', 'get').mockReturnValue('text');
expect(appConfig.disableNotificationsPage).toEqual(true);
expect(appConfig.disableFavicon).toEqual(true);
expect(appConfig.additionalDrawerLink).toEqual('link');
expect(appConfig.additionalDrawerLinkIcon).toEqual('icon');
expect(appConfig.additionalDrawerLinkText).toEqual('text');
});
});

View File

@@ -7,6 +7,7 @@ export default async (request, response) => {
isCloud: appConfig.isCloud,
isMation: appConfig.isMation,
isEnterprise: await hasValidLicense(),
docsUrl: appConfig.docsUrl,
};
renderObject(response, info);

View File

@@ -10,6 +10,7 @@ describe('GET /api/v1/automatisch/info', () => {
vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(false);
vi.spyOn(appConfig, 'isMation', 'get').mockReturnValue(false);
vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true);
vi.spyOn(appConfig, 'docsUrl', 'get').mockReturnValue('https://automatisch.io/docs');
const response = await request(app)
.get('/api/v1/automatisch/info')

View File

@@ -10,7 +10,7 @@ describe('GET /api/v1/automatisch/version', () => {
const expectedPayload = {
data: {
version: '0.11.0',
version: '0.12.0',
},
meta: {
count: 1,

View File

@@ -0,0 +1,21 @@
import User from '../../../../models/user.js';
export default async (request, response) => {
const { token, password } = request.body;
if (!token) {
throw new Error('Invitation token is required!');
}
const user = await User.query()
.findOne({ invitation_token: token })
.throwIfNotFound();
if (!user.isInvitationTokenValid()) {
return response.status(422).end();
}
await user.acceptInvitation(password);
response.status(204).end();
};

View File

@@ -0,0 +1,13 @@
import User from '../../../../models/user.js';
export default async (request, response) => {
const { email } = request.body;
const user = await User.query()
.findOne({ email: email.toLowerCase() })
.throwIfNotFound();
await user.sendResetPasswordEmail();
response.status(204).end();
};

View File

@@ -0,0 +1,30 @@
import { describe, it, beforeEach } from 'vitest';
import request from 'supertest';
import app from '../../../../app.js';
import { createUser } from '../../../../../test/factories/user';
describe('POST /api/v1/users/forgot-password', () => {
let currentUser;
beforeEach(async () => {
currentUser = await createUser();
});
it('should respond with no content', async () => {
await request(app)
.post('/api/v1/users/forgot-password')
.send({
email: currentUser.email,
})
.expect(204);
});
it('should return not found response for not existing user UUID', async () => {
await request(app)
.post('/api/v1/users/forgot-password')
.send({
email: 'nonexisting@automatisch.io',
})
.expect(404);
});
});

View File

@@ -0,0 +1,23 @@
import User from '../../../../models/user.js';
import { renderError } from '../../../../helpers/renderer.js';
export default async (request, response) => {
const { token, password } = request.body;
const user = await User.query()
.findOne({
reset_password_token: token,
})
.throwIfNotFound();
if (!user.isResetPasswordTokenValid()) {
return renderError(response, [{ general: [invalidTokenErrorMessage] }]);
}
await user.resetPassword(password);
response.status(204).end();
};
const invalidTokenErrorMessage =
'Reset password link is not valid or expired. Try generating a new link.';

View File

@@ -0,0 +1,49 @@
import { describe, it, beforeEach } from 'vitest';
import request from 'supertest';
import { DateTime } from 'luxon';
import app from '../../../../app.js';
import { createUser } from '../../../../../test/factories/user';
describe('POST /api/v1/users/reset-password', () => {
let currentUser;
beforeEach(async () => {
currentUser = await createUser({
resetPasswordToken: 'sampleResetPasswordToken',
resetPasswordTokenSentAt: DateTime.now().toISO(),
});
});
it('should respond with no content', async () => {
await request(app)
.post('/api/v1/users/reset-password')
.send({
token: currentUser.resetPasswordToken,
password: 'newPassword',
})
.expect(204);
});
it('should return not found response for not existing user', async () => {
await request(app)
.post('/api/v1/users/reset-password')
.send({
token: 'nonExistingResetPasswordToken',
})
.expect(404);
});
it('should return unprocessable entity for existing user with expired reset password token', async () => {
const user = await createUser({
resetPasswordToken: 'anotherResetPasswordToken',
resetPasswordTokenSentAt: DateTime.now().minus({ days: 2 }).toISO(),
});
await request(app)
.post('/api/v1/users/reset-password')
.send({
token: user.resetPasswordToken,
})
.expect(422);
});
});

View File

@@ -5,9 +5,11 @@ export async function up(knex) {
});
}
export async function down(knex) {
return knex.schema.alterTable('steps', (table) => {
table.string('key').notNullable().alter();
table.string('app_key').notNullable().alter();
});
export async function down() {
// We can't use down migration here since there are null values which needs to be set!
// We don't want to set those values by default key and app key since it will mislead users.
// return knex.schema.alterTable('steps', (table) => {
// table.string('key').notNullable().alter();
// table.string('app_key').notNullable().alter();
// });
}

View File

@@ -0,0 +1,11 @@
export async function up(knex) {
return knex.schema.alterTable('datastore', (table) => {
table.text('value').alter();
});
}
export async function down(knex) {
return knex.schema.alterTable('datastore', (table) => {
table.string('value').alter();
});
}

View File

@@ -0,0 +1,11 @@
export async function up(knex) {
return knex.schema.table('users', (table) => {
table.string('status').defaultTo('active');
});
}
export async function down(knex) {
return knex.schema.table('users', (table) => {
table.dropColumn('status');
});
}

View File

@@ -0,0 +1,13 @@
export async function up(knex) {
return knex.schema.table('users', (table) => {
table.string('invitation_token');
table.timestamp('invitation_token_sent_at');
});
}
export async function down(knex) {
return knex.schema.table('users', (table) => {
table.dropColumn('invitation_token');
table.dropColumn('invitation_token_sent_at');
});
}

View File

@@ -10,15 +10,11 @@ import deleteCurrentUser from './mutations/delete-current-user.ee.js';
import deleteFlow from './mutations/delete-flow.js';
import deleteRole from './mutations/delete-role.ee.js';
import deleteStep from './mutations/delete-step.js';
import deleteUser from './mutations/delete-user.ee.js';
import duplicateFlow from './mutations/duplicate-flow.js';
import executeFlow from './mutations/execute-flow.js';
import forgotPassword from './mutations/forgot-password.ee.js';
import generateAuthUrl from './mutations/generate-auth-url.js';
import login from './mutations/login.js';
import registerUser from './mutations/register-user.ee.js';
import resetConnection from './mutations/reset-connection.js';
import resetPassword from './mutations/reset-password.ee.js';
import updateAppAuthClient from './mutations/update-app-auth-client.ee.js';
import updateAppConfig from './mutations/update-app-config.ee.js';
import updateConfig from './mutations/update-config.ee.js';
@@ -46,15 +42,11 @@ const mutationResolvers = {
deleteFlow,
deleteRole,
deleteStep,
deleteUser,
duplicateFlow,
executeFlow,
forgotPassword,
generateAuthUrl,
login,
registerUser,
resetConnection,
resetPassword,
updateAppAuthClient,
updateAppConfig,
updateConfig,

View File

@@ -1,10 +1,16 @@
import appConfig from '../../config/app.js';
import User from '../../models/user.js';
import Role from '../../models/role.js';
import emailQueue from '../../queues/email.js';
import {
REMOVE_AFTER_30_DAYS_OR_150_JOBS,
REMOVE_AFTER_7_DAYS_OR_50_JOBS,
} from '../../helpers/remove-job-configuration.js';
const createUser = async (_parent, params, context) => {
context.currentUser.can('create', 'User');
const { fullName, email, password } = params.input;
const { fullName, email } = params.input;
const existingUser = await User.query().findOne({
email: email.toLowerCase(),
@@ -17,7 +23,7 @@ const createUser = async (_parent, params, context) => {
const userPayload = {
fullName,
email,
password,
status: 'invited',
};
try {
@@ -32,7 +38,29 @@ const createUser = async (_parent, params, context) => {
const user = await User.query().insert(userPayload);
return user;
await user.generateInvitationToken();
const jobName = `Invitation Email - ${user.id}`;
const acceptInvitationUrl = `${appConfig.webAppUrl}/accept-invitation?token=${user.invitationToken}`;
const jobPayload = {
email: user.email,
subject: 'You are invited!',
template: 'invitation-instructions',
params: {
fullName: user.fullName,
acceptInvitationUrl,
},
};
const jobOptions = {
removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS,
removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS,
};
await emailQueue.add(jobName, jobPayload, jobOptions);
return { user, acceptInvitationUrl };
};
export default createUser;

View File

@@ -1,24 +0,0 @@
import { Duration } from 'luxon';
import User from '../../models/user.js';
import deleteUserQueue from '../../queues/delete-user.ee.js';
const deleteUser = async (_parent, params, context) => {
context.currentUser.can('delete', 'User');
const id = params.input.id;
await User.query().deleteById(id);
const jobName = `Delete user - ${id}`;
const jobPayload = { id };
const millisecondsFor30Days = Duration.fromObject({ days: 30 }).toMillis();
const jobOptions = {
delay: millisecondsFor30Days,
};
await deleteUserQueue.add(jobName, jobPayload, jobOptions);
return true;
};
export default deleteUser;

View File

@@ -1,43 +0,0 @@
import appConfig from '../../config/app.js';
import User from '../../models/user.js';
import emailQueue from '../../queues/email.js';
import {
REMOVE_AFTER_30_DAYS_OR_150_JOBS,
REMOVE_AFTER_7_DAYS_OR_50_JOBS,
} from '../../helpers/remove-job-configuration.js';
const forgotPassword = async (_parent, params) => {
const { email } = params.input;
const user = await User.query().findOne({ email: email.toLowerCase() });
if (!user) {
throw new Error('Email address not found!');
}
await user.generateResetPasswordToken();
const jobName = `Reset Password Email - ${user.id}`;
const jobPayload = {
email: user.email,
subject: 'Reset Password',
template: 'reset-password-instructions',
params: {
token: user.resetPasswordToken,
webAppUrl: appConfig.webAppUrl,
fullName: user.fullName,
},
};
const jobOptions = {
removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS,
removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS,
};
await emailQueue.add(jobName, jobPayload, jobOptions);
return true;
};
export default forgotPassword;

View File

@@ -1,17 +0,0 @@
import User from '../../models/user.js';
import createAuthTokenByUserId from '../../helpers/create-auth-token-by-user-id.js';
const login = async (_parent, params) => {
const user = await User.query().findOne({
email: params.input.email.toLowerCase(),
});
if (user && (await user.login(params.input.password))) {
const token = await createAuthTokenByUserId(user.id);
return { token, user };
}
throw new Error('User could not be found.');
};
export default login;

View File

@@ -1,23 +0,0 @@
import User from '../../models/user.js';
const resetPassword = async (_parent, params) => {
const { token, password } = params.input;
if (!token) {
throw new Error('Reset password token is required!');
}
const user = await User.query().findOne({ reset_password_token: token });
if (!user || !user.isResetPasswordTokenValid()) {
throw new Error(
'Reset password link is not valid or expired. Try generating a new link.'
);
}
await user.resetPassword(password);
return true;
};
export default resetPassword;

View File

@@ -8,21 +8,17 @@ type Mutation {
createFlow(input: CreateFlowInput): Flow
createRole(input: CreateRoleInput): Role
createStep(input: CreateStepInput): Step
createUser(input: CreateUserInput): User
createUser(input: CreateUserInput): UserWithAcceptInvitationUrl
deleteConnection(input: DeleteConnectionInput): Boolean
deleteCurrentUser: Boolean
deleteFlow(input: DeleteFlowInput): Boolean
deleteRole(input: DeleteRoleInput): Boolean
deleteStep(input: DeleteStepInput): Step
deleteUser(input: DeleteUserInput): Boolean
duplicateFlow(input: DuplicateFlowInput): Flow
executeFlow(input: ExecuteFlowInput): executeFlowType
forgotPassword(input: ForgotPasswordInput): Boolean
generateAuthUrl(input: GenerateAuthUrlInput): AuthLink
login(input: LoginInput): Auth
registerUser(input: RegisterUserInput): User
resetConnection(input: ResetConnectionInput): Connection
resetPassword(input: ResetPasswordInput): Boolean
updateAppAuthClient(input: UpdateAppAuthClientInput): AppAuthClient
updateAppConfig(input: UpdateAppConfigInput): AppConfig
updateConfig(input: JSONObject): JSONObject
@@ -154,11 +150,6 @@ enum ArgumentEnumType {
string
}
type Auth {
user: User
token: String
}
type AuthenticationStep {
type: String
name: String
@@ -375,7 +366,6 @@ input DeleteStepInput {
input CreateUserInput {
fullName: String!
email: String!
password: String!
role: UserRoleInput!
}
@@ -390,10 +380,6 @@ input UpdateUserInput {
role: UserRoleInput
}
input DeleteUserInput {
id: String!
}
input RegisterUserInput {
fullName: String!
email: String!
@@ -406,20 +392,6 @@ input UpdateCurrentUserInput {
fullName: String
}
input ForgotPasswordInput {
email: String!
}
input ResetPasswordInput {
token: String!
password: String!
}
input LoginInput {
email: String!
password: String!
}
input PermissionInput {
action: String!
subject: String!
@@ -520,6 +492,11 @@ type User {
updatedAt: String
}
type UserWithAcceptInvitationUrl {
user: User
acceptInvitationUrl: String
}
type Role {
id: String
name: String

View File

@@ -53,10 +53,7 @@ const isAuthenticatedRule = rule()(isAuthenticated);
export const authenticationRules = {
Mutation: {
'*': isAuthenticatedRule,
forgotPassword: allow,
login: allow,
registerUser: allow,
resetPassword: allow,
},
};

View File

@@ -1,43 +1,102 @@
import axios from 'axios';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { HttpProxyAgent } from 'http-proxy-agent';
import appConfig from '../config/app.js';
const config = axios.defaults;
const httpProxyUrl = process.env.http_proxy;
const httpsProxyUrl = process.env.https_proxy;
const supportsProxy = httpProxyUrl || httpsProxyUrl;
const noProxyEnv = process.env.no_proxy;
const noProxyHosts = noProxyEnv ? noProxyEnv.split(',').map(host => host.trim()) : [];
export function createInstance(customConfig = {}, { requestInterceptor, responseErrorInterceptor } = {}) {
const config = {
...axios.defaults,
...customConfig
};
const httpProxyUrl = appConfig.httpProxy;
const httpsProxyUrl = appConfig.httpsProxy;
const supportsProxy = httpProxyUrl || httpsProxyUrl;
const noProxyEnv = appConfig.noProxy;
const noProxyHosts = noProxyEnv ? noProxyEnv.split(',').map(host => host.trim()) : [];
if (supportsProxy) {
if (httpProxyUrl) {
config.httpAgent = new HttpProxyAgent(httpProxyUrl);
if (supportsProxy) {
if (httpProxyUrl) {
config.httpAgent = new HttpProxyAgent(httpProxyUrl);
}
if (httpsProxyUrl) {
config.httpsAgent = new HttpsProxyAgent(httpsProxyUrl);
}
config.proxy = false;
}
if (httpsProxyUrl) {
config.httpsAgent = new HttpsProxyAgent(httpsProxyUrl);
const instance = axios.create(config);
function shouldSkipProxy(hostname) {
return noProxyHosts.some(noProxyHost => {
return hostname.endsWith(noProxyHost) || hostname === noProxyHost;
});
};
/**
* The interceptors are executed in the reverse order they are added.
*/
instance.interceptors.request.use(
function skipProxyIfInNoProxy(requestConfig) {
const hostname = new URL(requestConfig.baseURL).hostname;
if (supportsProxy && shouldSkipProxy(hostname)) {
requestConfig.httpAgent = undefined;
requestConfig.httpsAgent = undefined;
}
return requestConfig;
},
(error) => Promise.reject(error)
);
// not always we have custom request interceptors
if (requestInterceptor) {
instance.interceptors.request.use(
async function customInterceptor(requestConfig) {
let newRequestConfig = requestConfig;
for (const interceptor of requestInterceptor) {
newRequestConfig = await interceptor(newRequestConfig);
}
return newRequestConfig;
}
);
}
config.proxy = false;
instance.interceptors.request.use(
function removeBaseUrlForAbsoluteUrls(requestConfig) {
/**
* If the URL is an absolute URL, we remove its origin out of the URL
* and set it as baseURL. This lets us streamlines the requests made by Automatisch
* and requests made by app integrations.
*/
try {
const url = new URL(requestConfig.url);
requestConfig.baseURL = url.origin;
requestConfig.url = url.pathname + url.search;
return requestConfig;
} catch (err) {
return requestConfig;
}
},
(error) => Promise.reject(error)
);
// not always we have custom response error interceptor
if (responseErrorInterceptor) {
instance.interceptors.response.use(
(response) => response,
responseErrorInterceptor
);
}
return instance;
}
const axiosWithProxyInstance = axios.create(config);
const defaultInstance = createInstance();
function shouldSkipProxy(hostname) {
return noProxyHosts.some(noProxyHost => {
return hostname.endsWith(noProxyHost) || hostname === noProxyHost;
});
};
axiosWithProxyInstance.interceptors.request.use(function skipProxyIfInNoProxy(requestConfig) {
const hostname = new URL(requestConfig.url).hostname;
if (supportsProxy && shouldSkipProxy(hostname)) {
requestConfig.httpAgent = undefined;
requestConfig.httpsAgent = undefined;
}
return requestConfig;
});
export default axiosWithProxyInstance;
export default defaultInstance;

View File

@@ -0,0 +1,169 @@
import { beforeEach, describe, it, expect, vi } from 'vitest';
describe('Custom default axios with proxy', () => {
beforeEach(() => {
vi.resetModules();
});
it('should have two interceptors by default', async () => {
const axios = (await import('./axios-with-proxy.js')).default;
const requestInterceptors = axios.interceptors.request.handlers;
expect(requestInterceptors.length).toBe(2);
});
it('should have default interceptors in a certain order', async () => {
const axios = (await import('./axios-with-proxy.js')).default;
const requestInterceptors = axios.interceptors.request.handlers;
const firstRequestInterceptor = requestInterceptors[0];
const secondRequestInterceptor = requestInterceptors[1];
expect(firstRequestInterceptor.fulfilled.name).toBe('skipProxyIfInNoProxy');
expect(secondRequestInterceptor.fulfilled.name).toBe('removeBaseUrlForAbsoluteUrls');
});
it('should throw with invalid url (consisting of path alone)', async () => {
const axios = (await import('./axios-with-proxy.js')).default;
await expect(() => axios('/just-a-path')).rejects.toThrowError('Invalid URL');
});
describe('with skipProxyIfInNoProxy interceptor', () => {
let appConfig, axios;
beforeEach(async() => {
appConfig = (await import('../config/app.js')).default;
vi.spyOn(appConfig, 'httpProxy', 'get').mockReturnValue('http://proxy.automatisch.io');
vi.spyOn(appConfig, 'httpsProxy', 'get').mockReturnValue('http://proxy.automatisch.io');
vi.spyOn(appConfig, 'noProxy', 'get').mockReturnValue('name.tld,automatisch.io');
axios = (await import('./axios-with-proxy.js')).default;
});
it('should skip proxy for hosts in no_proxy environment variable', async () => {
const skipProxyIfInNoProxy = axios.interceptors.request.handlers[0].fulfilled;
const mockRequestConfig = {
...axios.defaults,
baseURL: 'https://automatisch.io'
};
const interceptedRequestConfig = skipProxyIfInNoProxy(mockRequestConfig);
expect(interceptedRequestConfig.httpAgent).toBeUndefined();
expect(interceptedRequestConfig.httpsAgent).toBeUndefined();
expect(interceptedRequestConfig.proxy).toBe(false);
});
it('should not skip proxy for hosts not in no_proxy environment variable', async () => {
const skipProxyIfInNoProxy = axios.interceptors.request.handlers[0].fulfilled;
const mockRequestConfig = {
...axios.defaults,
// beware the intentional typo!
baseURL: 'https://automatish.io'
};
const interceptedRequestConfig = skipProxyIfInNoProxy(mockRequestConfig);
expect(interceptedRequestConfig.httpAgent).toBeDefined();
expect(interceptedRequestConfig.httpsAgent).toBeDefined();
expect(interceptedRequestConfig.proxy).toBe(false);
});
});
describe('with removeBaseUrlForAbsoluteUrls interceptor', () => {
let axios;
beforeEach(async() => {
axios = (await import('./axios-with-proxy.js')).default;
});
it('should trim the baseUrl from absolute urls', async () => {
const removeBaseUrlForAbsoluteUrls = axios.interceptors.request.handlers[1].fulfilled;
const mockRequestConfig = {
...axios.defaults,
url: 'https://automatisch.io/path'
};
const interceptedRequestConfig = removeBaseUrlForAbsoluteUrls(mockRequestConfig);
expect(interceptedRequestConfig.baseURL).toBe('https://automatisch.io');
expect(interceptedRequestConfig.url).toBe('/path');
});
it('should not mutate separate baseURL and urls', async () => {
const removeBaseUrlForAbsoluteUrls = axios.interceptors.request.handlers[1].fulfilled;
const mockRequestConfig = {
...axios.defaults,
baseURL: 'https://automatisch.io',
url: '/path?query=1'
};
const interceptedRequestConfig = removeBaseUrlForAbsoluteUrls(mockRequestConfig);
expect(interceptedRequestConfig.baseURL).toBe('https://automatisch.io');
expect(interceptedRequestConfig.url).toBe('/path?query=1');
});
it('should not strip querystring from url', async () => {
const removeBaseUrlForAbsoluteUrls = axios.interceptors.request.handlers[1].fulfilled;
const mockRequestConfig = {
...axios.defaults,
url: 'https://automatisch.io/path?query=1'
};
const interceptedRequestConfig = removeBaseUrlForAbsoluteUrls(mockRequestConfig);
expect(interceptedRequestConfig.baseURL).toBe('https://automatisch.io');
expect(interceptedRequestConfig.url).toBe('/path?query=1');
});
});
describe('with extra requestInterceptors', () => {
it('should apply extra request interceptors in the middle', async () => {
const { createInstance } = await import('./axios-with-proxy.js');
const interceptor = (config) => {
config.test = true;
return config;
}
const instance = createInstance({}, {
requestInterceptor: [
interceptor
]
});
const requestInterceptors = instance.interceptors.request.handlers;
const customInterceptor = requestInterceptors[1].fulfilled;
expect(requestInterceptors.length).toBe(3);
await expect(customInterceptor({})).resolves.toStrictEqual({ test: true });
});
it('should work with a custom interceptor setting a baseURL and a request to path', async () => {
const { createInstance } = await import('./axios-with-proxy.js');
const interceptor = (config) => {
config.baseURL = 'http://localhost';
return config;
}
const instance = createInstance({}, {
requestInterceptor: [
interceptor
]
});
try {
await instance.get('/just-a-path');
} catch (error) {
expect(error.config.baseURL).toBe('http://localhost');
expect(error.config.url).toBe('/just-a-path');
}
})
});
});

View File

@@ -6,7 +6,7 @@ import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const compileEmail = (emailPath, replacements = {}) => {
const filePath = path.join(__dirname, `../views/emails/${emailPath}.ee.hbs`);
const filePath = path.join(__dirname, `../views/emails/${emailPath}.hbs`);
const source = fs.readFileSync(filePath, 'utf-8').toString();
const template = handlebars.compile(source);
return template(replacements);

View File

@@ -98,9 +98,9 @@ const globalVariable = async (options) => {
});
return {
key: datastore.key,
value: datastore.value,
[datastore.key]: datastore.value,
key: key,
value: datastore?.value ?? null,
[key]: datastore?.value ?? null,
};
},
set: async ({ key, value }) => {

View File

@@ -1,68 +1,43 @@
import { URL } from 'node:url';
import HttpError from '../../errors/http.js';
import axios from '../axios-with-proxy.js';
const removeBaseUrlForAbsoluteUrls = (requestConfig) => {
try {
const url = new URL(requestConfig.url);
requestConfig.baseURL = url.origin;
requestConfig.url = url.pathname + url.search;
return requestConfig;
} catch {
return requestConfig;
}
};
import { createInstance } from '../axios-with-proxy.js';
export default function createHttpClient({ $, baseURL, beforeRequest = [] }) {
const instance = axios.create({
baseURL,
});
async function interceptResponseError(error) {
const { config, response } = error;
// Do not destructure `status` from `error.response` because it might not exist
const status = response?.status;
instance.interceptors.request.use((requestConfig) => {
const newRequestConfig = removeBaseUrlForAbsoluteUrls(requestConfig);
if (
// TODO: provide a `shouldRefreshToken` function in the app
(status === 401 || status === 403) &&
$.app.auth &&
$.app.auth.refreshToken &&
!$.app.auth.isRefreshTokenRequested
) {
$.app.auth.isRefreshTokenRequested = true;
await $.app.auth.refreshToken($);
const result = beforeRequest.reduce((newConfig, beforeRequestFunc) => {
return beforeRequestFunc($, newConfig);
}, newRequestConfig);
// retry the previous request before the expired token error
const newResponse = await instance.request(config);
$.app.auth.isRefreshTokenRequested = false;
/**
* axios seems to want InternalAxiosRequestConfig returned not AxioRequestConfig
* anymore even though requests do require AxiosRequestConfig.
*
* Since both interfaces are very similar (InternalAxiosRequestConfig
* extends AxiosRequestConfig), we can utilize an assertion below
**/
return result;
});
instance.interceptors.response.use(
(response) => response,
async (error) => {
const { config, response } = error;
// Do not destructure `status` from `error.response` because it might not exist
const status = response?.status;
if (
// TODO: provide a `shouldRefreshToken` function in the app
(status === 401 || status === 403) &&
$.app.auth &&
$.app.auth.refreshToken &&
!$.app.auth.isRefreshTokenRequested
) {
$.app.auth.isRefreshTokenRequested = true;
await $.app.auth.refreshToken($);
// retry the previous request before the expired token error
const newResponse = await instance.request(config);
$.app.auth.isRefreshTokenRequested = false;
return newResponse;
}
throw new HttpError(error);
return newResponse;
}
);
throw new HttpError(error);
};
const instance = createInstance(
{
baseURL,
},
{
requestInterceptor: beforeRequest.map((originalBeforeRequest) => {
return async (requestConfig) => await originalBeforeRequest($, requestConfig);
}),
responseErrorInterceptor: interceptResponseError,
}
)
return instance;
}

View File

@@ -63,6 +63,8 @@ export default async (flowId, request, response) => {
});
if (testRun) {
response.status(204).end();
// in case of testing, we do not process the whole process.
continue;
}
@@ -74,6 +76,12 @@ export default async (flowId, request, response) => {
executionId,
});
if (actionStep.appKey === 'filter' && !actionExecutionStep.dataOut) {
response.status(422).end();
break;
}
if (actionStep.key === 'respondWith' && !response.headersSent) {
const { headers, statusCode, body } = actionExecutionStep.dataOut;

View File

@@ -1,5 +1,5 @@
import bcrypt from 'bcrypt';
import { DateTime } from 'luxon';
import { DateTime, Duration } from 'luxon';
import crypto from 'node:crypto';
import appConfig from '../config/app.js';
@@ -21,6 +21,13 @@ import Subscription from './subscription.ee.js';
import UsageData from './usage-data.ee.js';
import Billing from '../helpers/billing/index.ee.js';
import deleteUserQueue from '../queues/delete-user.ee.js';
import emailQueue from '../queues/email.js';
import {
REMOVE_AFTER_30_DAYS_OR_150_JOBS,
REMOVE_AFTER_7_DAYS_OR_50_JOBS,
} from '../helpers/remove-job-configuration.js';
class User extends Base {
static tableName = 'users';
@@ -33,8 +40,21 @@ class User extends Base {
fullName: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 },
password: { type: 'string' },
resetPasswordToken: { type: 'string' },
resetPasswordTokenSentAt: { type: 'string' },
status: {
type: 'string',
enum: ['active', 'invited'],
default: 'active',
},
resetPasswordToken: { type: ['string', 'null'] },
resetPasswordTokenSentAt: {
type: ['string', 'null'],
format: 'date-time',
},
invitationToken: { type: ['string', 'null'] },
invitationTokenSentAt: {
type: ['string', 'null'],
format: 'date-time',
},
trialExpiryDate: { type: 'string' },
roleId: { type: 'string', format: 'uuid' },
deletedAt: { type: 'string' },
@@ -202,6 +222,13 @@ class User extends Base {
await this.$query().patch({ resetPasswordToken, resetPasswordTokenSentAt });
}
async generateInvitationToken() {
const invitationToken = crypto.randomBytes(64).toString('hex');
const invitationTokenSentAt = new Date().toISOString();
await this.$query().patch({ invitationToken, invitationTokenSentAt });
}
async resetPassword(password) {
return await this.$query().patch({
resetPasswordToken: null,
@@ -210,7 +237,53 @@ class User extends Base {
});
}
async isResetPasswordTokenValid() {
async acceptInvitation(password) {
return await this.$query().patch({
invitationToken: null,
invitationTokenSentAt: null,
status: 'active',
password,
});
}
async softRemove() {
await this.$query().delete();
const jobName = `Delete user - ${this.id}`;
const jobPayload = { id: this.id };
const millisecondsFor30Days = Duration.fromObject({ days: 30 }).toMillis();
const jobOptions = {
delay: millisecondsFor30Days,
};
await deleteUserQueue.add(jobName, jobPayload, jobOptions);
}
async sendResetPasswordEmail() {
await this.generateResetPasswordToken();
const jobName = `Reset Password Email - ${this.id}`;
const jobPayload = {
email: this.email,
subject: 'Reset Password',
template: 'reset-password-instructions.ee',
params: {
token: this.resetPasswordToken,
webAppUrl: appConfig.webAppUrl,
fullName: this.fullName,
},
};
const jobOptions = {
removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS,
removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS,
};
await emailQueue.add(jobName, jobPayload, jobOptions);
}
isResetPasswordTokenValid() {
if (!this.resetPasswordTokenSentAt) {
return false;
}
@@ -222,6 +295,18 @@ class User extends Base {
return now.getTime() - sentAt.getTime() < fourHoursInMilliseconds;
}
isInvitationTokenValid() {
if (!this.invitationTokenSentAt) {
return false;
}
const sentAt = new Date(this.invitationTokenSentAt);
const now = new Date();
const seventyTwoHoursInMilliseconds = 1000 * 60 * 60 * 72;
return now.getTime() - sentAt.getTime() < seventyTwoHoursInMilliseconds;
}
async generateHash() {
if (this.password) {
this.password = await bcrypt.hash(this.password, 10);
@@ -381,7 +466,7 @@ class User extends Base {
email,
password,
fullName,
roleId: adminRole.id
roleId: adminRole.id,
});
await Config.markInstallationCompleted();

View File

@@ -4,6 +4,7 @@ import { authenticateUser } from '../../../../helpers/authentication.js';
import { authorizeAdmin } from '../../../../helpers/authorization.js';
import getUsersAction from '../../../../controllers/api/v1/admin/users/get-users.ee.js';
import getUserAction from '../../../../controllers/api/v1/admin/users/get-user.ee.js';
import deleteUserAction from '../../../../controllers/api/v1/admin/users/delete-user.js';
const router = Router();
@@ -16,4 +17,11 @@ router.get(
asyncHandler(getUserAction)
);
router.delete(
'/:userId',
authenticateUser,
authorizeAdmin,
asyncHandler(deleteUserAction)
);
export default router;

View File

@@ -9,6 +9,9 @@ import getAppsAction from '../../../controllers/api/v1/users/get-apps.js';
import getInvoicesAction from '../../../controllers/api/v1/users/get-invoices.ee.js';
import getSubscriptionAction from '../../../controllers/api/v1/users/get-subscription.ee.js';
import getPlanAndUsageAction from '../../../controllers/api/v1/users/get-plan-and-usage.ee.js';
import acceptInvitationAction from '../../../controllers/api/v1/users/accept-invitation.js';
import forgotPasswordAction from '../../../controllers/api/v1/users/forgot-password.js';
import resetPasswordAction from '../../../controllers/api/v1/users/reset-password.js';
const router = Router();
@@ -49,4 +52,9 @@ router.get(
asyncHandler(getPlanAndUsageAction)
);
router.post('/invitation', asyncHandler(acceptInvitationAction));
router.post('/forgot-password', asyncHandler(forgotPasswordAction));
router.post('/reset-password', asyncHandler(resetPasswordAction));
export default router;

View File

@@ -8,6 +8,7 @@ const userSerializer = (user) => {
email: user.email,
createdAt: user.createdAt.getTime(),
updatedAt: user.updatedAt.getTime(),
status: user.status,
fullName: user.fullName,
};

View File

@@ -35,6 +35,7 @@ describe('userSerializer', () => {
email: user.email,
fullName: user.fullName,
id: user.id,
status: user.status,
updatedAt: user.updatedAt.getTime(),
};

View File

@@ -0,0 +1,23 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Invitation instructions</title>
</head>
<body>
<p>
Hello {{ fullName }},
</p>
<p>
You have been invited to join our platform. To accept the invitation, click the link below.
</p>
<p>
<a href="{{ acceptInvitationUrl }}">Accept invitation</a>
</p>
<p>
If you did not expect this invitation, you can ignore this email.
</p>
</body>
</html>

View File

@@ -9,7 +9,7 @@
</p>
<p>
Someone has requested a link to change your password, and you can do this through the link below.
Someone has requested a link to change your password, and you can do this through the link below within 72 hours.
</p>
<p>

View File

@@ -40,6 +40,7 @@ export const worker = new Worker(
await user.$relatedQuery('usageData').withSoftDeleted().hardDelete();
}
await user.$relatedQuery('accessTokens').withSoftDeleted().hardDelete();
await user.$query().withSoftDeleted().hardDelete();
},
{ connection: redisConfig }

View File

@@ -21,7 +21,7 @@ export const worker = new Worker(
async (job) => {
const { email, subject, template, params } = job.data;
if (isCloudSandbox && !isAutomatischEmail(email)) {
if (isCloudSandbox() && !isAutomatischEmail(email)) {
logger.info(
'Only Automatisch emails are allowed for non-production environments!'
);

View File

@@ -14,6 +14,7 @@ const getUserMock = (currentUser, role) => {
name: role.name,
updatedAt: role.updatedAt.getTime(),
},
status: currentUser.status,
trialExpiryDate: currentUser.trialExpiryDate.toISOString(),
updatedAt: currentUser.updatedAt.getTime(),
},

View File

@@ -18,6 +18,7 @@ const getUsersMock = async (users, roles) => {
updatedAt: role.updatedAt.getTime(),
}
: null,
status: user.status,
trialExpiryDate: user.trialExpiryDate.toISOString(),
updatedAt: user.updatedAt.getTime(),
};

View File

@@ -4,6 +4,7 @@ const infoMock = () => {
isCloud: false,
isMation: false,
isEnterprise: true,
docsUrl: 'https://automatisch.io/docs',
},
meta: {
count: 1,

View File

@@ -23,6 +23,7 @@ const getCurrentUserMock = (currentUser, role, permissions) => {
name: role.name,
updatedAt: role.updatedAt.getTime(),
},
status: currentUser.status,
trialExpiryDate: currentUser.trialExpiryDate.toISOString(),
updatedAt: currentUser.updatedAt.getTime(),
},

Some files were not shown because too many files have changed in this diff Show More