From 7264239b839b8e92b7ea667ec70e5c3edb578277 Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck <1849459+BHesseldieck@users.noreply.github.com> Date: Mon, 14 Mar 2022 14:46:32 +0100 Subject: [PATCH] feat: Add User Management (#2636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * โœ… adjust tests * ๐Ÿ›  refactor user invites to be indempotent (#2791) * ๐Ÿ” Encrypt SMTP pass for user management backend (#2793) * :package: Add crypto-js to /cli * :package: Update package-lock.json * :sparkles: Create type for SMTP config * :zap: Encrypt SMTP pass * :zap: Update format for `userManagement.emails.mode` * :zap: Update format for `binaryDataManager.mode` * :zap: Update format for `logs.level` * :fire: Remove logging * :shirt: Fix lint * ๐Ÿ‘ฐ n8n 2826 um wedding FE<>BE (#2789) * remove mocks * update authorization func * lock down default role * ๐Ÿ› fix requiring authentication for OPTIONS requests * :bug: fix cors and cookie issues in dev * update setup route Co-authored-by: Ben Hesseldieck * update telemetry * ๐Ÿ› preload role for users * :bug: remove auth for password reset routes * ๐Ÿ› fix forgot-password flow * :zap: allow workflow tag disabling * update telemetry init * add reset * clear error notifications on signin * remove load settings from node view * remove user id from user state * inherit existing user props * go back in history on button click * use replace to force redirect * update stories * :zap: add env check for tag create * :test_tube: Add `/users` tests for user management backend (#2790) * :zap: Refactor users namespace * :zap: Adjust fillout endpoint * :zap: Refactor initTestServer arg * :pencil2: Specify agent type * :pencil2: Specify role type * :zap: Tighten `/users/:id` check * :sparkles: Add initial tests * :truck: Reposition init server map * :zap: Set constants in `validatePassword()` * :zap: Tighten `/users/:id` check * :zap: Improve checks in `/users/:id` * :sparkles: Add tests for `/users/:id` * :package: Update package-lock.json * :zap: Simplify expectation * :zap: Reuse util for authless agent * :truck: Make role names consistent * :blue_book: Tighten namespaces map type * :fire: Remove unneeded default arg * :sparkles: Add tests for `POST /users` * :blue_book: Create test SMTP account type * :pencil2: Improve wording * :art: Formatting * :fire: Remove temp fix * :zap: Replace helper with config call * :zap: Fix failing tests * :fire: Remove outdated test * :fire: Remove unused helper * :zap: Increase readability of domain fetcher * :zap: Refactor payload validation * :fire: Remove repetition * :rewind: Restore logging * :zap: Initialize logger in tests * :fire: Remove redundancy from check * :truck: Move `globalOwnerRole` fetching to global scope * :fire: Remove unused imports * :truck: Move random utils to own module * :truck: Move test types to own module * :pencil2: Add dividers to utils * :pencil2: Reorder `initTestServer` param docstring * :pencil2: Add TODO comment * :zap: Dry up member creation * :zap: Tighten search criteria * :test_tube: Add expectation to `GET /users` * :zap: Create role fetcher utils * :zap: Create one more role fetch util * :fire: Remove unneeded DB query * :test_tube: Add expectation to `POST /users` * :test_tube: Add expectation to `DELETE /users/:id` * :test_tube: Add another expectation to `DELETE /users/:id` * :test_tube: Add expectations to `DELETE /users/:id` * :test_tube: Adjust expectations in `POST /users/:id` * :test_tube: Add expectations to `DELETE /users/:id` * :shirt: Fix build * :zap: Update method * :blue_book: Fix `userToDelete` type * :zap: Refactor `createAgent()` * :zap: Make role fetching global * :zap: Optimize roles fetching * :zap: Centralize member creation * :zap: Refactor truncation helper * :test_tube: Add teardown to `DELETE /users/:id` * :test_tube: Add DB expectations to users tests * :fire: Remove pass validation due to hash * :pencil2: Improve pass validation error message * :zap: Improve owner pass validation * :zap: Create logger initialization helper * :zap: Optimize helpers * :zap: Restructure `getAllRoles` helper * :test_tube: Add password reset flow tests for user management backend (#2807) * :zap: Refactor users namespace * :zap: Adjust fillout endpoint * :zap: Refactor initTestServer arg * :pencil2: Specify agent type * :pencil2: Specify role type * :zap: Tighten `/users/:id` check * :sparkles: Add initial tests * :truck: Reposition init server map * :zap: Set constants in `validatePassword()` * :zap: Tighten `/users/:id` check * :zap: Improve checks in `/users/:id` * :sparkles: Add tests for `/users/:id` * :package: Update package-lock.json * :zap: Simplify expectation * :zap: Reuse util for authless agent * :truck: Make role names consistent * :blue_book: Tighten namespaces map type * :fire: Remove unneeded default arg * :sparkles: Add tests for `POST /users` * :blue_book: Create test SMTP account type * :pencil2: Improve wording * :art: Formatting * :fire: Remove temp fix * :zap: Replace helper with config call * :zap: Fix failing tests * :fire: Remove outdated test * :sparkles: Add tests for password reset flow * :pencil2: Fix test wording * :zap: Set password reset namespace * :fire: Remove unused helper * :zap: Increase readability of domain fetcher * :zap: Refactor payload validation * :fire: Remove repetition * :rewind: Restore logging * :zap: Initialize logger in tests * :fire: Remove redundancy from check * :truck: Move `globalOwnerRole` fetching to global scope * :fire: Remove unused imports * :truck: Move random utils to own module * :truck: Move test types to own module * :pencil2: Add dividers to utils * :pencil2: Reorder `initTestServer` param docstring * :pencil2: Add TODO comment * :zap: Dry up member creation * :zap: Tighten search criteria * :test_tube: Add expectation to `GET /users` * :zap: Create role fetcher utils * :zap: Create one more role fetch util * :fire: Remove unneeded DB query * :test_tube: Add expectation to `POST /users` * :test_tube: Add expectation to `DELETE /users/:id` * :test_tube: Add another expectation to `DELETE /users/:id` * :test_tube: Add expectations to `DELETE /users/:id` * :test_tube: Adjust expectations in `POST /users/:id` * :test_tube: Add expectations to `DELETE /users/:id` * :blue_book: Add namespace name to type * :truck: Adjust imports * :zap: Optimize `globalOwnerRole` fetching * :test_tube: Add expectations * :shirt: Fix build * :shirt: Fix build * :zap: Update method * :zap: Update method * :test_tube: Fix `POST /change-password` test * :blue_book: Fix `userToDelete` type * :zap: Refactor `createAgent()` * :zap: Make role fetching global * :zap: Optimize roles fetching * :zap: Centralize member creation * :zap: Refactor truncation helper * :test_tube: Add teardown to `DELETE /users/:id` * :test_tube: Add DB expectations to users tests * :zap: Refactor as in users namespace * :test_tube: Add expectation to `POST /change-password` * :fire: Remove pass validation due to hash * :pencil2: Improve pass validation error message * :zap: Improve owner pass validation * :zap: Create logger initialization helper * :zap: Optimize helpers * :zap: Restructure `getAllRoles` helper * :zap: Update `truncate` calls * :bug: return 200 for non-existing user * โœ… fix tests for forgot-password and user creation * Update packages/editor-ui/src/components/MainSidebar.vue Co-authored-by: Ahsan Virani * Update packages/editor-ui/src/components/Telemetry.vue Co-authored-by: Ahsan Virani * Update packages/editor-ui/src/plugins/telemetry/index.ts Co-authored-by: Ahsan Virani * Update packages/editor-ui/src/plugins/telemetry/index.ts Co-authored-by: Ahsan Virani * Update packages/editor-ui/src/plugins/telemetry/index.ts Co-authored-by: Ahsan Virani * :truck: Fix imports * :zap: reset password just if password exists * Fix validation at `PATCH /workfows/:id` (#2819) * :bug: Validate entity only if workflow * :shirt: Fix build * ๐Ÿ”จ refactor response from user creation * ๐Ÿ› um email invite fix (#2833) * update users invite * fix notificaitons stacking on top of each other * remove unnessary check * fix type issues * update structure * fix types * ๐Ÿ˜ database migrations UM + password reset expiration (#2710) * Add table prefix and assign existing workflows and credentials to owner for sqlite * Added user management migration to MySQL * Fixed some missing table prefixes and removed unnecessary user id * Created migration for postgres and applies minor fixes * Fixed migration for sqlite by removing the unnecessary index and for mysql by removing unnecessary user data * Added password reset token expiration * Addressing comments made by Ben * โšก๏ธ add missing tablePrefix * โœ… fix tests + add tests for expiring pw-reset-token Co-authored-by: Ben Hesseldieck * :zap: treat skipped personalizationSurvey as not answered * :bug: removing active workflows when deleting user, :bug: fix reinvite, :bug: fix resolve-signup-token, ๐Ÿ˜ remove workflowname uniqueness * โœ… Add DB state check tests (#2841) * :fire: Remove unneeded import * :fire: Remove unneeded vars * :pencil2: Improve naming * :test_tube: Add expectations to `POST /owner` * :test_tube: Add expectations to `PATCH /me` * :test_tube: Add expectation to `PATCH /me/password` * :pencil2: Clarify when owner is owner shell * :test_tube: Add more expectations * :rewind: Restore package-lock to parent branch state * Add logging to user management endpoints v2 (#2836) * :zap: Initialize logger in tests * :zap: Add logs to mailer * :zap: Add logs to middleware * :zap: Add logs to me endpoints * :zap: Add logs to owner endpoints * :zap: Add logs to pass flow endpoints * :zap: Add logs to users endpoints * :blue_book: Improve typings * :zap: Merge two logs into one * :zap: Adjust log type * :zap: Add password reset email log * :pencil2: Reword log message * :zap: Adjust log meta object * :zap: Add total to log * :pencil2: Add detail to log message * :pencil2: Reword log message * :pencil2: Reword log message * :bug: Make total users to set up accurate * :pencil2: Reword `Logger.debug()` messages * :pencil2: Phrasing change for consistency * :bug: Fix ID overridden in range query * :hammer: small refactoring * ๐Ÿ” add auth to push-connection * ๐Ÿ›  โœ… Create credentials namespace and add tests (#2831) * :test_tube: Fix failing test * :blue_book: Improve `createAgent` signature * :truck: Fix `LoggerProxy` import * :sparkles: Create credentials endpoints namespace * :test_tube: Set up initial tests * :zap: Add validation to model * :zap: Adjust validation * :test_tube: Add test * :truck: Sort creds endpoints * :pencil2: Plan out pending tests * :test_tube: Add deletion tests * :test_tube: Add patch tests * :test_tube: Add get cred tests * :truck: Hoist import * :pencil2: Make test descriptions consistent * :pencil2: Adjust description * :test_tube: Add missing test * :pencil2: Make get descriptions consistent * :rewind: Undo line break * :zap: Refactor to simplify `saveCredential` * :test_tube: Add non-owned tests for owner * :pencil2: Improve naming * :pencil2: Add clarifying comments * :truck: Improve imports * :zap: Initialize config file * :fire: Remove unneeded import * :truck: Rename dir * :zap: Adjust deletion call * :zap: Adjust error code * :pencil2: Touch up comment * :zap: Optimize fetching with `@RelationId` * :test_tube: Add expectations * :zap: Simplify mock calls * :blue_book: Set deep readonly to object constants * :fire: Remove unused param and encryption key * :zap: Add more `@RelationId` calls in models * :rewind: Restore * :bug: no auth for .svg * ๐Ÿ›  move auth cookie name to constant; ๐Ÿ› fix auth for push-connection * โœ… Add auth middleware tests (#2853) * :zap: Simplify existing suite * :test_tube: Validate that auth cookie exists * :pencil2: Move comment * :fire: Remove unneeded imports * :pencil2: Add clarifying comments * :pencil2: Document auth endpoints * :test_tube: Add middleware tests * :pencil2: Fix typos Co-authored-by: Ben Hesseldieck <1849459+BHesseldieck@users.noreply.github.com> * ๐Ÿ”ฅ Remove test description wrappers (#2874) * :fire: Remove /owner test wrappers * :fire: Remove auth middleware test wrappers * :fire: Remove auth endpoints test wrappers * :fire: Remove overlooked middleware wrappers * :fire: Remove me namespace test wrappers Co-authored-by: Ben Hesseldieck * โœจ Runtime checks for credentials load and execute workflows (#2697) * Runtime checks for credentials load and execute workflows * Fixed from reviewers * Changed runtime validation for credentials to be on start instead of on demand * Refactored validations to use user id instead of whole User instance * Removed user entity from workflow project because it is no longer needed * General fixes and improvements to runtime checks * Remove query builder and improve styling * Fix lint issues * :zap: remove personalizationAnswers when fetching all users * โœ… fix failing get all users test * โœ… check authorization routes also for authentication * :bug: fix defaults in reset command * ๐Ÿ›  refactorings from walkthrough (#2856) * :zap: Make `getTemplate` async * :zap: Remove query builder from `getCredentials` * :zap: Add save manual executions log message * :rewind: Restore and hide migrations logs * :zap: Centralize ignore paths check * :shirt: Fix build * :truck: Rename `hasOwner` to `isInstanceOwnerSetUp` * :zap: Add `isSetUp` flag to `User` * :zap: Add `isSetUp` to FE interface * :zap: Adjust `isSetUp` checks on FE * :shirt: Fix build * :zap: Adjust `isPendingUser()` check * :truck: Shorten helper name * :zap: Refactor as `isPending` per feedback * :pencil2: Update log message * :zap: Broaden check * :fire: Remove unneeded relation * :zap: Refactor query * :fire: Re-remove logs from migrations * ๐Ÿ›  set up credentials router (#2882) * :zap: Refactor creds endpoints into router * :test_tube: Refactor creds tests to use router * :truck: Rename arg for consistency * :truck: Move `credentials.api.ts` outside /public * :truck: Rename constant for consistency * :blue_book: Simplify types * :fire: Remove unneeded arg * :truck: Rename router to controller * :zap: Shorten endpoint * :zap: Update `initTestServer()` arg * :zap: Mutate response body in GET /credentials * ๐ŸŽ improve performance of type cast for FE Co-authored-by: Ben Hesseldieck * :bug: remove GET /login from auth * ๐Ÿ”€ merge master + FE update (#2905) * :sparkles: Add Templates (#2720) * Templates Bugs / Fixed Various Bugs / Multiply Api Request, Carousel Gradient, Core Nodes Filters ... * Updated MainSidebar Paddings * N8N-Templates Bugfixing - Remove Unnecesairy Icon (Shape), Refatctor infiniteScrollEnabled Prop + updated infiniterScroll functinality * N8N-2853 Fixed Carousel Arrows Bug after Cleaning the SearchBar * fix telemetry init * fix search tracking issues * N8N-2853 Created FilterTemplateNode Constant Array, Filter PlayButton and WebhookRespond from Nodes, Added Box for showing more nodes inside TemplateList, Updated NewWorkflowButton to primary, Fixed Markdown issue with Code * N8N-2853 Removed Placeholder if Workflows Or Collections are not found, Updated the Logic * fix telemetry events * clean up session id * update user inserted event * N8N-2853 Fixed Categories to Moving if the names are long * Add todos * Update Routes on loading * fix spacing * Update Border Color * Update Border Readius * fix filter fn * fix constant, console error * N8N-2853 PR Fixes, Refactoring, Removing unnecesairy code .. * N8N-2853 PR Fixes - Editor-ui Fixes, Refactoring, Removing Dead Code ... * N8N-2853 Refactor Card to LongCard * clean up spacing, replace css var * clean up spacing * set categories as optional in node * replace vars * refactor store * remove unnesssary import * fix error * fix templates view to start * add to cache * fix coll view data * fix categories * fix category event * fix collections carousel * fix initial load and search * fix infinite load * fix query param * fix scrolling issues * fix scroll to top * fix search * fix collections search * fix navigation bug * rename view * update package lock * rename workflow view * rename coll view * update routes * add wrapper component * set session id * fix search tracking * fix session tracking * remove deleted mutation * remove check for unsupported nodes * refactor filters * lazy load template * clean up types * refactor infinte scroll * fix end of search * Fix spacing * fix coll loading * fix types * fix coll view list * fix navigation * rename types * rename state * fix search responsiveness * fix coll view spacing * fix search view spacing * clean up views * set background color * center page not vert * fix workflow view * remove import * fix background color * fix background * clean props * clean up imports * refactor button * update background color * fix spacing issue * rename event * update telemetry event * update endpoints, add loading view, check for endpoint health * remove conolse log * N8N-2853 Fixed Menu Items Padding * replace endpoints * fix type issues * fix categories * N8N-2853 Fixed ParameterInput Placeholder after ElementUI Upgrade * update createdAt * :zap: Fix placeholder in creds config modal * :pencil2: Adjust docstring to `credText` placeholder version * N8N-2853 Optimized * N8N-2853 Optimized code * :zap: Add deployment type to FE settings * :zap: Add deployment type to interfaces * N8N-2853 Removed Animated prop from components * :zap: Add deployment type to store module * :sparkles: Create hiring banner * :zap: Display hiring banner * :rewind: Undo unrelated change * N8N-2853 Refactor TemplateFilters * :zap: Fix indentation * N8N-2853 Reorder items / TemplateList * :shirt: Fix lint * N8N-2853 Refactor TemplateFilters Component * N8N-2853 Reorder TemplateList * refactor template card * update timeout * fix removelistener * fix spacing * split enabled from offline * add spacing to go back * N8N-2853 Fixed Screens for Tablet & Mobile * N8N-2853 Update Stores Order * remove image componet * remove placeholder changes * N8N-2853 Fixed Chinnese Placeholders for El Select Component that comes from the Library Upgrade * N8N-2853 Fixed Vue Agile Console Warnings * N8N-2853 Update Collection Route * :pencil2: Update jobs URL * :truck: Move logging to root component * :zap: Refactor `deploymentType` to `isInternalUser` * :zap: Improve syntax * fix cut bug in readonly view * N8N-3012 Fixed Details section in templates with lots of description, Fixed Mardown Block with overflox-x * N8N-3012 Increased Font-size, Spacing and Line-height of the Categories Items * N8N-3012 Fixed Vue-agile client width error on resize * only delay redirect for root path * N8N-3012 Fixed Carousel Arrows that Disappear * N8N-3012 Make Loading Screen same color as Templates * N8N-3012 Markdown renders inline block as block code * add offline warning * hide log from workflow iframe * update text * make search button larger * N8N-3012 Categories / Tags extended all the way in details section * load data in cred modals * remove deleted message * add external hook * remove import * update env variable description * fix markdown width issue * disable telemetry for demo, add session id to template pages * fix telemetery bugs * N8N-3012 Not found Collections/Wokrkflow * N8N-3012 Checkboxes change order when categories are changed * N8N-3012 Refactor SortedCategories inside TemplateFilters component * fix firefox bug * add telemetry requirements * add error check * N8N-3012 Update GoBackButton to check if Route History is present * N8N-3012 Fixed WF Nodes Icons * hide workflow screenshots * remove unnessary mixins * rename prop * fix design a bit * rename data * clear workspace on destroy * fix copy paste bug * fix disabled state * N8N-3012 Fixed Saving/Leave without saving Modal * fix telemetry issue * fix telemetry issues, error bug * fix error notification * disable workflow menu items on templates * fix i18n elementui issue * Remove Emit - NodeType from HoverableNodeIcon component * TechnicalFixes: NavigateTo passed down as function should be helper * TechnicalFixes: Update NavigateTo function * TechnicalFixes: Add FilterCoreNodes directly as function * check for empty connecitions * fix titles * respect new lines * increase categories to be sliced * rename prop * onUseWorkflow * refactor click event * fix bug, refactor * fix loading story * add default * fix styles at right level of abstraction * add wrapper with width * remove loading blocks component * add story * rename prop * fix spacing * refactor tag, add story * move margin to container * fix tag redirect, remove unnessary check * make version optional * rename view * move from workflows to templates store * remove unnessary change * remove unnessary css * rename component * refactor collection card * add boolean to prevent shrink * clean up carousel * fix redirection bug on save * remove listeners to fix multiple listeners bug * remove unnessary types * clean up boolean set * fix node select bug * rename component * remove unnessary class * fix redirection bug * remove unnessary error * fix typo * fix blockquotes, pre * refactor markdown rendering * remove console log * escape markdown * fix safari bug * load active workflows to fix modal bug * :arrow_up: Update package-lock.json file * :zap: Add n8n version as header Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Co-authored-by: Mutasem Co-authored-by: Ivรกn Ovejero Co-authored-by: Jan Oberhauser * :bookmark: Releaseย n8n-workflow@0.88.0 * :arrow_up: Set n8n-workflow@0.88.0 on n8n-core * :bookmark: Releaseย n8n-core@0.106.0 * :arrow_up: Set n8n-core@0.106.0 and n8n-workflow@0.88.0 on n8n-node-dev * :bookmark: Releaseย n8n-node-dev@0.45.0 * :arrow_up: Set n8n-core@0.106.0 and n8n-workflow@0.88.0 on n8n-nodes-base * :bookmark: Releaseย n8n-nodes-base@0.163.0 * :bookmark: Releaseย n8n-design-system@0.12.0 * :arrow_up: Set n8n-design-system@0.12.0 and n8n-workflow@0.88.0 on n8n-editor-ui * :bookmark: Releaseย n8n-editor-ui@0.132.0 * :arrow_up: Set n8n-core@0.106.0, n8n-editor-ui@0.132.0, n8n-nodes-base@0.163.0 and n8n-workflow@0.88.0 on n8n * :bookmark: Releaseย n8n@0.165.0 * fix default user bug * fix bug * update package lock * fix duplicate import * fix settings * fix templates access Co-authored-by: Oliver Trajceski Co-authored-by: Ivรกn Ovejero Co-authored-by: Jan Oberhauser * :zap: n8n 2952 personalisation (#2911) * refactor/update survey * update customers * Fix up personalization survey * fix recommendation logic * set to false * hide suggested nodes when empty * use keys * add missing logic * switch types * Fix logic * remove unused constants * add back constant * refactor filtering inputs * hide last input on personal * fix other * โœจ add current pw check for change password (#2912) * fix back button * Add current password input * add to modal * update package.json * delete mock file * delete mock file * get settings func * update router * update package lock * update package lock * Fix invite text * update error i18n * open personalization on search if not set * update error view i18n * update change password * update settings sidebar * remove import * fix sidebar * :goal_net: fix error for credential/workflow not found * update invite modal * โœจ persist skipping owner setup (#2894) * ๐Ÿšง added skipInstanceOwnerSetup to DB + route to save skipping * โœจ skipping owner setup persists * โœ… add tests for authorization and /owner/skip-setup * ๐Ÿ›  refactor FE settings getter * ๐Ÿ›  move setting setup stop to owner creation * :bug: fix wrong setting of User.isPending * :bug: fix isPending * ๐Ÿท add isPending to PublicUser * :bug: fix unused import * update delete modal * change password modal * remove _label * sort keys * remove key * update key names * fix test endpoint * ๐Ÿฅ… Handle error workflows permissions (#2908) * Handle error workflows permissions * Fixed wrong query format * ๐Ÿ›  refactor query Co-authored-by: Ben Hesseldieck <1849459+BHesseldieck@users.noreply.github.com> * fix ts issue * fix list after ispending changes * fix error page bugs * fix error redirect * fix notification * :bug: fix survey import in migration * fix up spacing * update keys spacing * update keys * add space * update key * fix up more spacing * ๐Ÿ” add current password (#2919) * add curr pass * update key names * :bug: stringify tag ids * ๐Ÿ” check current password before update * add package lock * fix dep version * update version * ๐Ÿ› fix access for instance owner to credentials (#2927) * ๐Ÿ›  stringify tag id on entity * ๐Ÿ” Update password requirements (#2920) * :zap: Update password requirements * :zap: Adjust random helpers * โœ… fix tests for currentPassword check * change redirection, add homepage * fix error view redirection * updated wording * fix setup redirection * update validator * remove successfully * update consumers * update settings redirect * on signup, redirect to homepage * update empty state * add space to emails * remove brackets * add opacity * update spacing * remove border from last user * personal details updated * update redirect on sign up * prevent text wrap * fix notification title line height * remove console log * ๐Ÿ˜ Support testing with Postgres and MySQL (#2886) * :card_file_box: Fix Postgres migrations * :zap: Add DB-specific scripts * :sparkles: Set up test connections * :zap: Add Postgres UUID check * :test_tube: Make test adjustments for Postgres * :zap: Refactor connection logic * :sparkles: Set up double init for Postgres * :pencil2: Add TODOs * :zap: Refactor DB dropping logic * :sparkles: Implement global teardown * :sparkles: Create TypeORM wrappers * :sparkles: Initial MySQL setup * :zap: Clean up Postgres connection options * :zap: Simplify by sharing bootstrap connection name * :card_file_box: Fix MySQL migrations * :fire: Remove comments * :zap: Use ES6 imports * :fire: Remove outdated comments * :zap: Centralize bootstrap connection name handles * :zap: Centralize database types * :pencil2: Update comment * :truck: Rename `findRepository` * :construction: Attempt to truncate MySQL * :sparkles: Implement creds router * :bug: Fix duplicated MySQL bootstrap * :bug: Fix misresolved merge conflict * :card_file_box: Fix tags migration * :card_file_box: Fix MySQL UM migration * :bug: Fix MySQL parallelization issues * :blue_book: Augment TypeORM to prevent error * :fire: Remove comments * :sparkles: Support one sqlite DB per suite run * :truck: Move `testDb` to own module * :fire: Deduplicate bootstrap Postgres logic * :fire: Remove unneeded comment * :zap: Make logger init calls consistent * :pencil2: Improve comment * :pencil2: Add dividers * :art: Improve formatting * :fire: Remove duplicate MySQL global setting * :truck: Move comment * :zap: Update default test script * :fire: Remove unneeded helper * :zap: Unmarshal answers from Postgres * :bug: Phase out `isTestRun` * :zap: Refactor `isEmailSetup` * :fire: Remove unneeded imports * :zap: Handle bootstrap connection errors * :fire: Remove unneeded imports * :fire: Remove outdated comments * :pencil2: Fix typos * :truck: Relocate `answersFormatter` * :rewind: Undo package.json miscommit * :fire: Remove unneeded import * :zap: Refactor test DB prefixing * :zap: Add no-leftover check to MySQL * :package: Update package.json * :zap: Autoincrement on simulated MySQL truncation * :fire: Remove debugging queries * โœ๏ธ fix email template link expiry * ๐Ÿ”ฅ remove unused import * โœ… fix testing email not sent error * fix duplicate import * add package lock * fix export * change opacity * fix text issue * update action box * update error title * update forgot password * update survey * update product text * remove unset fields * add category to page events * remove duplicate import * update key * update key * update label type * ๐ŸŽจ um/fe review (#2946) * :whale: Update Node.js versions of Docker images to 16 * :bug: Fix that some keyboard shortcuts did no longer work * N8N-3057 Fixed Keyboard shortcuts no longer working on / Fixed callDebounced function * N8N-3057 Update Debounce Function * N8N-3057 Refactor callDebounce function * N8N-3057 Update Dobounce Function * :bug: Fix issue with tooltips getting displayed behind node details view * fix tooltips z-index * move all element ui components * update package lock * :bug: Fix credentials list load issue (#2931) * always fetch credentials * only fetch credentials once * :zap: Allow to disable hiring banner (#2902) * :sparkles: Add flag * :zap: Adjust interfaces * :zap: Adjust store module * :zap: Adjust frontend settings * :zap: Adjust frontend display * :bug: Fix issue that ctrl + o did behave wrong on workflow templates page (#2934) * N8N-3094 Workflow Templates cmd-o acts on the Preview/Iframe * N8N-3094 Workflow Templates cmd-o acts on the Preview/Iframe * disable shortcuts for preview Co-authored-by: Mutasem * :arrow_up: Update package-lock.json file * :bug: Fix sorting by field in Baserow Node (#2942) This fixes a bug which currently leads to the "Sorting" option of the node to be ignored. * :bug: Fix some i18n line break issues * :sparkles: Add Odoo Node (#2601) * added odoo scaffolding * update getting data from odoo instance * added scaffolding for main loop and request functions * added functions for CRUD opperations * improoved error handling for odooJSONRPCRequest * updated odoo node and fixing nodelinter issues * fixed alpabetical order * fixed types in odoo node * fixing linter errors * fixing linter errors * fixed data shape returned from man loop * updated node input types, added fields list to models * update when custom resource is selected options for fields list will be populated dynamicly * minor fixes * :hammer: fixed credential test, updating CRUD methods * :hammer: added additional fields to crm resource * :hammer: added descriptions, fixed credentials test bug * :hammer: standardize node and descriptions design * :hammer: removed comments * :hammer: added pagination to getAll operation * :zap: removed leftover function from previous implementation, removed required from optional fields * :zap: fixed id field, added indication of type and if required to field description, replaced string input in filters to fetched list of fields * :hammer: fetching list of models from odoo, added selection of fields to be returned to predefined models, fixes accordingly to review * :zap: Small improvements * :hammer: extracted adress fields into collection, changed fields to include in descriptions, minor tweaks * :zap: Improvements * :hammer: working on review * :hammer: fixed linter errors * :hammer: review wip * :hammer: review wip * :hammer: review wip * :zap: updated display name for URL in credentials * :hammer: added checks for valid id to delete and update * :zap: Minor improvements Co-authored-by: ricardo Co-authored-by: Jan Oberhauser * :bug: Handle Wise SCA requests (#2734) * :zap: Improve Wise error message after previous change * fix duplicate import * add package lock * fix export * change opacity * fix text issue * update action box * update error title * update forgot password * update survey * update product text * remove unset fields * add category to page events * remove duplicate import * update key * update key Co-authored-by: Jan Oberhauser Co-authored-by: Oliver Trajceski Co-authored-by: Ivรกn Ovejero Co-authored-by: Tom <19203795+that-one-tom@users.noreply.github.com> Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com> Co-authored-by: ricardo Co-authored-by: pemontto <939704+pemontto@users.noreply.github.com> * Move owner skip from settings * ๐Ÿ› SMTP fixes (#2937) * :fire: Remove `UM_` from SMTP env vars * :fire: Remove SMTP host default value * :zap: Update sender value * :zap: Update invite template * :zap: Update password reset template * :zap: Update `N8N_EMAIL_MODE` default value * :fire: Remove `EMAIL` from all SMTP vars * :sparkles: Implement `verifyConnection()` * :truck: Reposition comment * :pencil2: Fix typo * :pencil2: Minor env var documentation improvements * :art: Fix spacing * :art: Fix spacing * :card_file_box: Remove SMTP settings cache * :zap: Adjust log message * :zap: Update error message * :pencil2: Fix template typo * :pencil2: Adjust wording * :zap: Interpolate email into success toast * :pencil2: Adjust base message in `verifyConnection()` * :zap: Verify connection on password reset * :zap: Bring up POST /users SMTP check * :bug: remove cookie if cookie is not valid * :zap: verify connection on instantiation Co-authored-by: Ben Hesseldieck * ๐Ÿ”Š create logger helper for migrations (#2944) * ๐Ÿ”ฅ remove unused database * :loud_sound: add migration logging for sqlite * ๐Ÿ”ฅ remove unnecessary index creation * โšก๏ธ change log level to warn * ๐Ÿ› Fix issue with workflow process to initialize db connection correctly (#2948) * โœ๏ธ update error messages for webhhook run/activation * ๐Ÿ“ˆ Implement telemetry events (#2868) * Implement basic telemetry events * Fixing user id as part of the telemetry data * Added user id to be part of the tracked data * :sparkles: Create telemetry mock * :test_tube: Fix tests with telemetry mock * :test_tube: Fix missing key in authless endpoint * :blue_book: Create authless request type * :fire: Remove log * :bug: Fix `migration_strategy` assignment * :blue_book: Remove `instance_id` from `ITelemetryUserDeletionData` * :zap: Simplify concatenation * :zap: Simplify `track()` call signature * Fixed payload of telemetry to always include user_id * Fixing minor issues Co-authored-by: Ivรกn Ovejero * ๐Ÿ”Š Added logs to credentials, executions and workflows (#2915) * Added logs to credentials, executions and workflows * Some updates according to ivov's feedback * :zap: update log levels * โœ… fix tests Co-authored-by: Ben Hesseldieck * :bug: fix telemetry error * fix conflicts with master * fix duplicate * add package-lock * :bug: Um/fixes (#2952) * add initials to avatar * redirect to signin if invalid token * update pluralization * add auth page category * data transferred * touch up setup page * update button to add cursor * fix personalization modal not closing * โœ๏ธ fix environment name * ๐Ÿ› fix disabling UM * ๐Ÿ› fix email setup flag * ๐Ÿ› FE fixes 1 (#2953) * add initials to avatar * redirect to signin if invalid token * update pluralization * add auth page category * data transferred * touch up setup page * update button to add cursor * fix personalization modal not closing * capitalize labels, refactor text * Fixed the issue with telemetry data missing for personalization survey * Changed invite email text * ๐Ÿ› Fix quotes issue with postgres migration (#2958) * Changed text for invite link * ๐Ÿ› fix reset command for mysql * โœ… fix race condition in test DB creation * ๐Ÿ” block user creation if UM is disabled * ๐Ÿฅ… improve smtp setup issue error * :zap: update error message * refactor route rules * set package lock * fix access * remove capitalize * update input labels * refactor heading * change span to fragment * add route types * refactor views * โœ… fix increase timeout for mysql * :zap: correct logic of error message * refactor view names * :zap: update randomString * ๐Ÿ“ˆ Added missing event regarding failed emails (#2964) * replace label with info * ๐Ÿ›  refactor JWT-secret creation * remove duplicate key * remove unused part * remove semicolon * fix up i18n pattern * update translation keys * update urls * support i18n in nds * fix how external keys are handled * add source * ๐Ÿ’ฅ update timestamp of UM migration * โœ๏ธ small message updates * fix tracking * update notification line-height * fix avatar opacity * fix up empty state * shift focus to input * ๐Ÿ” Disable basic auth after owner has been set up (#2973) * Disable basic auth after owner has been set up * Remove unnecessary comparison * rename modal title * ๐Ÿ› use pgcrypto extension for uuid creation (#2977) * ๐Ÿ“ง Added public url variable for emails (#2967) * Added public url variable for emails * Fixed base url for reset password - the current implementation overrides possibly existing path * Change variable name to editorUrl * Using correct name editorUrl for emails * Changed variable description * Improved base url naming and appending path so it remains consistent * Removed trailing slash from editor base url * ๐ŸŒ fix i18n pattern (#2970) * fix up i18n pattern * update translation keys * update urls * support i18n in nds * fix how external keys are handled * add source * Um/fixes 1000 (#2980) * fix select issue * ๐Ÿ˜ซ hacky solution to circumvent pgcrypto (#2979) * fix owner bug after transfer. always fetch latest credentials * add confirmation modal to setup * Use webhook url as fallback when editor url is not defined * fix enter bug * update modal * update modal * update modal text, fix bug in settings view * Updating editor url to not append path * rename keys Co-authored-by: Ivรกn Ovejero Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Co-authored-by: Mutasem Co-authored-by: Ahsan Virani Co-authored-by: Omar Ajoue Co-authored-by: Oliver Trajceski Co-authored-by: Jan Oberhauser Co-authored-by: Tom <19203795+that-one-tom@users.noreply.github.com> Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com> Co-authored-by: ricardo Co-authored-by: pemontto <939704+pemontto@users.noreply.github.com> --- .eslintrc.js | 5 + package-lock.json | 2246 ++++++++++++----- packages/cli/commands/Interfaces.d.ts | 11 + packages/cli/commands/execute.ts | 3 + packages/cli/commands/executeBatch.ts | 7 + packages/cli/commands/import/credentials.ts | 172 +- packages/cli/commands/import/workflow.ts | 262 +- packages/cli/commands/start.ts | 41 + .../cli/commands/user-management/reset.ts | 86 + packages/cli/commands/worker.ts | 9 + packages/cli/config/index.ts | 99 +- packages/cli/jest.config.js | 17 + packages/cli/package.json | 47 +- packages/cli/packages/cli/database.sqlite | 0 packages/cli/src/ActiveWorkflowRunner.ts | 79 +- packages/cli/src/CredentialsHelper.ts | 85 +- packages/cli/src/Db.ts | 181 +- packages/cli/src/GenericHelpers.ts | 27 +- packages/cli/src/Interfaces.ts | 68 +- packages/cli/src/InternalHooks.ts | 110 +- packages/cli/src/PersonalizationSurvey.ts | 63 - packages/cli/src/ResponseHelper.ts | 12 +- packages/cli/src/Server.ts | 1849 +++++++------- packages/cli/src/TagHelpers.ts | 46 +- packages/cli/src/UserManagement/Interfaces.ts | 40 + .../UserManagement/UserManagementHelper.ts | 218 ++ packages/cli/src/UserManagement/auth/jwt.ts | 67 + .../src/UserManagement/email/Interfaces.ts | 32 + .../src/UserManagement/email/NodeMailer.ts | 74 + .../email/UserManagementMailer.ts | 98 + .../cli/src/UserManagement/email/index.ts | 4 + .../email/templates/instanceSetup.html | 5 + .../email/templates/invite.html | 4 + .../email/templates/passwordReset.html | 5 + packages/cli/src/UserManagement/index.ts | 4 + .../cli/src/UserManagement/routes/auth.ts | 119 + .../cli/src/UserManagement/routes/index.ts | 126 + packages/cli/src/UserManagement/routes/me.ts | 154 ++ .../cli/src/UserManagement/routes/owner.ts | 122 + .../UserManagement/routes/passwordReset.ts | 219 ++ .../cli/src/UserManagement/routes/users.ts | 562 +++++ packages/cli/src/WaitTracker.ts | 7 + packages/cli/src/WaitingWebhooks.ts | 10 +- packages/cli/src/WebhookHelpers.ts | 21 +- .../cli/src/WorkflowExecuteAdditionalData.ts | 95 +- packages/cli/src/WorkflowHelpers.ts | 102 +- packages/cli/src/WorkflowRunner.ts | 6 + packages/cli/src/WorkflowRunnerProcess.ts | 16 +- packages/cli/src/api/credentials.api.ts | 419 +++ packages/cli/src/constants.ts | 8 + .../databases/entities/CredentialsEntity.ts | 15 +- packages/cli/src/databases/entities/Role.ts | 77 + .../cli/src/databases/entities/Settings.ts | 18 + .../databases/entities/SharedCredentials.ts | 70 + .../src/databases/entities/SharedWorkflow.ts | 70 + .../cli/src/databases/entities/TagEntity.ts | 11 +- packages/cli/src/databases/entities/User.ts | 137 + .../src/databases/entities/WorkflowEntity.ts | 10 +- packages/cli/src/databases/entities/index.ts | 10 + .../migrations/1626183952959-AddWaitColumn.ts | 1 - ...1630451444017-UpdateWorkflowCredentials.ts | 3 - ...1644424784709-AddExecutionEntityIndexes.ts | 87 +- .../1646992772331-CreateUserManagement.ts | 171 ++ .../src/databases/mysqldb/migrations/index.ts | 2 + .../1620824779533-UniqueWorkflowNames.ts | 75 +- .../migrations/1626176912946-AddwaitTill.ts | 1 - ...1630419189837-UpdateWorkflowCredentials.ts | 3 - ...1644422880309-AddExecutionEntityIndexes.ts | 85 +- .../1646992772331-CreateUserManagement.ts | 160 ++ .../databases/postgresdb/migrations/index.ts | 2 + .../1588102412422-InitialMigration.ts | 37 +- .../migrations/1592445003908-WebhookModel.ts | 15 +- .../1594825041918-CreateIndexStoppedAt.ts | 13 +- .../1607431743769-MakeStoppedAtNullable.ts | 15 +- .../migrations/1611071044839-AddWebhookId.ts | 35 +- .../1617213344594-CreateTagEntity.ts | 116 +- .../1620821879465-UniqueWorkflowNames.ts | 61 +- .../migrations/1621707690587-AddWaitColumn.ts | 46 +- ...1630330987096-UpdateWorkflowCredentials.ts | 8 +- ...1644421939510-AddExecutionEntityIndexes.ts | 58 +- .../1646992772331-CreateUserManagement.ts | 118 + .../src/databases/sqlite/migrations/index.ts | 8 +- .../src/databases/utils/customValidators.ts | 19 + .../src/databases/utils/migrationHelpers.ts | 55 + .../cli/src/databases/utils/transformers.ts | 20 + packages/cli/src/requests.d.ts | 272 ++ packages/cli/src/telemetry/index.ts | 10 +- .../test/integration/auth.endpoints.test.ts | 147 ++ .../test/integration/auth.middleware.test.ts | 59 + .../test/integration/credentials.api.test.ts | 551 ++++ .../cli/test/integration/me.endpoints.test.ts | 529 ++++ .../test/integration/owner.endpoints.test.ts | 162 ++ .../passwordReset.endpoints.test.ts | 305 +++ .../test/integration/shared/augmentation.d.ts | 21 + .../cli/test/integration/shared/constants.ts | 59 + .../cli/test/integration/shared/random.ts | 41 + .../cli/test/integration/shared/testDb.ts | 389 +++ .../cli/test/integration/shared/types.d.ts | 28 + packages/cli/test/integration/shared/utils.ts | 220 ++ .../test/integration/users.endpoints.test.ts | 600 +++++ packages/cli/test/setup.ts | 34 + packages/cli/test/teardown.ts | 48 + .../test/{ => unit}/CredentialsHelper.test.ts | 2 +- packages/cli/test/{ => unit}/Helpers.ts | 0 packages/core/package.json | 6 +- packages/core/src/ActiveWebhooks.ts | 2 +- packages/core/test/Helpers.ts | 1 + .../.storybook/font-awesome-icons.js | 169 +- packages/design-system/.storybook/main.js | 4 +- packages/design-system/.storybook/preview.js | 2 +- packages/design-system/package.json | 27 +- .../N8nActionBox/ActionBox.stories.js | 33 + .../src/components/N8nActionBox/ActionBox.vue | 66 + .../src/components/N8nActionBox/index.js | 3 + .../N8nActionToggle/ActionToggle.stories.js | 43 + .../N8nActionToggle/ActionToggle.vue | 71 + .../src/components/N8nActionToggle/index.js | 3 + .../components/N8nAvatar/Avatar.stories.js | 25 + .../src/components/N8nAvatar/Avatar.vue | 89 + .../src/components/N8nAvatar/index.js | 3 + .../src/components/N8nBadge/Badge.stories.js | 28 + .../src/components/N8nBadge/Badge.vue | 58 + .../src/components/N8nBadge/index.js | 3 + .../src/components/N8nButton/Button.vue | 12 +- .../components/N8nFormBox/FormBox.stories.js | 69 + .../src/components/N8nFormBox/FormBox.vue | 160 ++ .../src/components/N8nFormBox/index.js | 3 + .../N8nFormInput/FormInput.stories.js | 35 + .../src/components/N8nFormInput/FormInput.vue | 253 ++ .../src/components/N8nFormInput/index.js | 3 + .../src/components/N8nFormInput/validators.ts | 158 ++ .../N8nFormInputs/FormInputs.stories.js | 73 + .../components/N8nFormInputs/FormInputs.vue | 131 + .../src/components/N8nFormInputs/index.js | 3 + .../src/components/N8nHeading/Heading.vue | 73 +- .../src/components/N8nIcon/index.d.ts | 13 - .../src/components/N8nIcon/index.js | 4 +- .../src/components/N8nInput/Input.stories.js | 6 +- .../src/components/N8nInput/Input.vue | 9 +- .../components/N8nInputLabel/InputLabel.vue | 14 +- .../src/components/N8nLink/Link.stories.js | 33 + .../src/components/N8nLink/Link.vue | 108 + .../src/components/N8nLink/index.js | 3 + .../src/components/N8nMenu/Menu.vue | 15 +- .../src/components/N8nRoute/Route.vue | 59 + .../src/components/N8nRoute/index.js | 3 + .../src/components/N8nSelect/Select.vue | 2 +- .../src/components/N8nSpinner/Spinner.vue | 2 +- .../src/components/N8nText/Text.vue | 75 +- .../N8nUserInfo/UserInfo.stories.js | 39 + .../src/components/N8nUserInfo/UserInfo.vue | 90 + .../src/components/N8nUserInfo/index.js | 3 + .../N8nUserSelect/UserSelect.stories.js | 69 + .../components/N8nUserSelect/UserSelect.vue | 145 ++ .../src/components/N8nUserSelect/index.js | 3 + .../N8nUsersList/UsersList.stories.js | 73 + .../src/components/N8nUsersList/UsersList.vue | 149 ++ .../src/components/N8nUsersList/index.js | 3 + .../ResizeObserver/ResizeObserver.vue | 69 + .../src/components/ResizeObserver/index.js | 3 + .../design-system/src/components/index.js | 35 +- packages/design-system/src/locale/format.js | 56 + packages/design-system/src/locale/index.js | 45 + packages/design-system/src/locale/lang/en.js | 19 + packages/design-system/src/mixins/locale.js | 9 + .../design-system/src/shims-element-ui.d.ts | 3 + .../src/styleguide/colors.stories.mdx | 12 + packages/design-system/src/utils.scss | 7 + packages/design-system/theme/src/_tokens.scss | 26 +- .../design-system/theme/src/common/var.scss | 6 +- .../design-system/theme/src/dropdown.scss | 5 +- packages/design-system/theme/src/index.scss | 2 +- packages/design-system/theme/src/menu.scss | 10 +- .../design-system/theme/src/message-box.scss | 2 - packages/design-system/theme/src/message.scss | 4 + .../design-system/theme/src/notification.scss | 4 +- packages/design-system/theme/src/option.scss | 4 +- packages/design-system/theme/src/reset.scss | 12 +- packages/design-system/theme/src/tooltip.scss | 1 + packages/editor-ui/package.json | 6 +- packages/editor-ui/public/n8n-logo.svg | 34 + packages/editor-ui/src/App.vue | 147 +- packages/editor-ui/src/Interface.ts | 145 +- packages/editor-ui/src/api/credentials.ts | 4 +- packages/editor-ui/src/api/helpers.ts | 5 +- packages/editor-ui/src/api/settings.ts | 24 +- packages/editor-ui/src/api/users.ts | 76 + packages/editor-ui/src/components/About.vue | 87 - .../editor-ui/src/components/AboutModal.vue | 80 + packages/editor-ui/src/components/Banner.vue | 6 +- .../src/components/ChangePasswordModal.vue | 139 + .../src/components/ContactPromptModal.vue | 10 +- .../CredentialEdit/CredentialConfig.vue | 4 +- .../CredentialEdit/CredentialEdit.vue | 8 +- .../editor-ui/src/components/DataDisplay.vue | 2 +- .../src/components/DeleteUserModal.vue | 179 ++ .../components/DuplicateWorkflowDialog.vue | 8 +- .../src/components/ExecutionsList.vue | 6 +- .../editor-ui/src/components/GoBackButton.vue | 3 +- .../src/components/InviteUsersModal.vue | 201 ++ packages/editor-ui/src/components/Logo.vue | 25 + .../src/components/MainHeader/MainHeader.vue | 3 +- .../components/MainHeader/WorkflowDetails.vue | 69 +- .../editor-ui/src/components/MainSidebar.vue | 142 +- ...temsIterator.vue => MenuItemsIterator.vue} | 0 packages/editor-ui/src/components/Modals.vue | 38 +- .../src/components/NodeCreator/NoResults.vue | 23 +- .../components/NodeCreator/NodeCreator.vue | 2 +- .../editor-ui/src/components/NodeSettings.vue | 14 +- .../editor-ui/src/components/PageAlert.vue | 47 + .../src/components/ParameterInput.vue | 12 - .../src/components/ParameterInputExpanded.vue | 12 +- .../src/components/PersonalizationModal.vue | 634 +++-- .../src/components/SettingsSidebar.vue | 107 + .../TagsManager/TagsView/TagsTable.vue | 2 +- .../TagsManager/TagsView/TagsView.vue | 6 +- .../editor-ui/src/components/Telemetry.vue | 14 +- .../editor-ui/src/components/UpdatesPanel.vue | 24 +- .../editor-ui/src/components/ValueSurvey.vue | 3 +- .../editor-ui/src/components/WorkflowOpen.vue | 16 +- .../src/components/WorkflowSettings.vue | 1 - .../src/components/mixins/genericHelpers.ts | 8 +- .../src/components/mixins/pushConnection.ts | 2 +- .../src/components/mixins/restApi.ts | 2 +- .../src/components/mixins/showMessage.ts | 6 +- .../src/components/mixins/userHelpers.ts | 29 + .../src/components/mixins/workflowHelpers.ts | 5 +- packages/editor-ui/src/constants.ts | 105 +- packages/editor-ui/src/modules/credentials.ts | 9 +- packages/editor-ui/src/modules/helper.ts | 92 - packages/editor-ui/src/modules/settings.ts | 69 +- packages/editor-ui/src/modules/ui.ts | 48 +- packages/editor-ui/src/modules/userHelpers.ts | 308 +++ packages/editor-ui/src/modules/users.ts | 228 ++ .../editor-ui/src/n8n-theme-variables.scss | 1 - packages/editor-ui/src/plugins/components.ts | 21 +- packages/editor-ui/src/plugins/i18n/index.ts | 9 + .../src/plugins/i18n/locales/en.json | 154 +- packages/editor-ui/src/plugins/icons.ts | 8 + .../editor-ui/src/plugins/telemetry/index.ts | 30 +- packages/editor-ui/src/router.ts | 280 +- packages/editor-ui/src/store.ts | 4 +- packages/editor-ui/src/views/AuthView.vue | 86 + .../src/views/ChangePasswordView.vue | 126 + packages/editor-ui/src/views/ErrorView.vue | 77 + .../src/views/ForgotMyPasswordView.vue | 100 + packages/editor-ui/src/views/NodeView.vue | 26 +- .../src/views/SettingsPersonalView.vue | 192 ++ .../editor-ui/src/views/SettingsUsersView.vue | 129 + packages/editor-ui/src/views/SettingsView.vue | 49 + packages/editor-ui/src/views/SetupView.vue | 164 ++ packages/editor-ui/src/views/SigninView.vue | 89 + packages/editor-ui/src/views/SignupView.vue | 113 + .../src/views/TemplatesCollectionView.vue | 5 +- .../src/views/TemplatesSearchView.vue | 8 +- .../src/views/TemplatesWorkflowView.vue | 21 +- .../editor-ui/tests/unit/placeholder.spec.ts | 5 - packages/nodes-base/package.json | 6 +- packages/nodes-base/test/placeholder.test.ts | 7 - packages/workflow/package.json | 6 +- packages/workflow/src/Interfaces.ts | 1 + packages/workflow/test/Helpers.ts | 1 + 262 files changed, 17737 insertions(+), 3294 deletions(-) create mode 100644 packages/cli/commands/user-management/reset.ts create mode 100644 packages/cli/jest.config.js delete mode 100644 packages/cli/packages/cli/database.sqlite delete mode 100644 packages/cli/src/PersonalizationSurvey.ts create mode 100644 packages/cli/src/UserManagement/Interfaces.ts create mode 100644 packages/cli/src/UserManagement/UserManagementHelper.ts create mode 100644 packages/cli/src/UserManagement/auth/jwt.ts create mode 100644 packages/cli/src/UserManagement/email/Interfaces.ts create mode 100644 packages/cli/src/UserManagement/email/NodeMailer.ts create mode 100644 packages/cli/src/UserManagement/email/UserManagementMailer.ts create mode 100644 packages/cli/src/UserManagement/email/index.ts create mode 100644 packages/cli/src/UserManagement/email/templates/instanceSetup.html create mode 100644 packages/cli/src/UserManagement/email/templates/invite.html create mode 100644 packages/cli/src/UserManagement/email/templates/passwordReset.html create mode 100644 packages/cli/src/UserManagement/index.ts create mode 100644 packages/cli/src/UserManagement/routes/auth.ts create mode 100644 packages/cli/src/UserManagement/routes/index.ts create mode 100644 packages/cli/src/UserManagement/routes/me.ts create mode 100644 packages/cli/src/UserManagement/routes/owner.ts create mode 100644 packages/cli/src/UserManagement/routes/passwordReset.ts create mode 100644 packages/cli/src/UserManagement/routes/users.ts create mode 100644 packages/cli/src/api/credentials.api.ts create mode 100644 packages/cli/src/constants.ts create mode 100644 packages/cli/src/databases/entities/Role.ts create mode 100644 packages/cli/src/databases/entities/Settings.ts create mode 100644 packages/cli/src/databases/entities/SharedCredentials.ts create mode 100644 packages/cli/src/databases/entities/SharedWorkflow.ts create mode 100644 packages/cli/src/databases/entities/User.ts create mode 100644 packages/cli/src/databases/mysqldb/migrations/1646992772331-CreateUserManagement.ts create mode 100644 packages/cli/src/databases/postgresdb/migrations/1646992772331-CreateUserManagement.ts create mode 100644 packages/cli/src/databases/sqlite/migrations/1646992772331-CreateUserManagement.ts create mode 100644 packages/cli/src/databases/utils/customValidators.ts create mode 100644 packages/cli/src/databases/utils/migrationHelpers.ts create mode 100644 packages/cli/src/databases/utils/transformers.ts create mode 100644 packages/cli/src/requests.d.ts create mode 100644 packages/cli/test/integration/auth.endpoints.test.ts create mode 100644 packages/cli/test/integration/auth.middleware.test.ts create mode 100644 packages/cli/test/integration/credentials.api.test.ts create mode 100644 packages/cli/test/integration/me.endpoints.test.ts create mode 100644 packages/cli/test/integration/owner.endpoints.test.ts create mode 100644 packages/cli/test/integration/passwordReset.endpoints.test.ts create mode 100644 packages/cli/test/integration/shared/augmentation.d.ts create mode 100644 packages/cli/test/integration/shared/constants.ts create mode 100644 packages/cli/test/integration/shared/random.ts create mode 100644 packages/cli/test/integration/shared/testDb.ts create mode 100644 packages/cli/test/integration/shared/types.d.ts create mode 100644 packages/cli/test/integration/shared/utils.ts create mode 100644 packages/cli/test/integration/users.endpoints.test.ts create mode 100644 packages/cli/test/setup.ts create mode 100644 packages/cli/test/teardown.ts rename packages/cli/test/{ => unit}/CredentialsHelper.test.ts (99%) rename packages/cli/test/{ => unit}/Helpers.ts (100%) create mode 100644 packages/design-system/src/components/N8nActionBox/ActionBox.stories.js create mode 100644 packages/design-system/src/components/N8nActionBox/ActionBox.vue create mode 100644 packages/design-system/src/components/N8nActionBox/index.js create mode 100644 packages/design-system/src/components/N8nActionToggle/ActionToggle.stories.js create mode 100644 packages/design-system/src/components/N8nActionToggle/ActionToggle.vue create mode 100644 packages/design-system/src/components/N8nActionToggle/index.js create mode 100644 packages/design-system/src/components/N8nAvatar/Avatar.stories.js create mode 100644 packages/design-system/src/components/N8nAvatar/Avatar.vue create mode 100644 packages/design-system/src/components/N8nAvatar/index.js create mode 100644 packages/design-system/src/components/N8nBadge/Badge.stories.js create mode 100644 packages/design-system/src/components/N8nBadge/Badge.vue create mode 100644 packages/design-system/src/components/N8nBadge/index.js create mode 100644 packages/design-system/src/components/N8nFormBox/FormBox.stories.js create mode 100644 packages/design-system/src/components/N8nFormBox/FormBox.vue create mode 100644 packages/design-system/src/components/N8nFormBox/index.js create mode 100644 packages/design-system/src/components/N8nFormInput/FormInput.stories.js create mode 100644 packages/design-system/src/components/N8nFormInput/FormInput.vue create mode 100644 packages/design-system/src/components/N8nFormInput/index.js create mode 100644 packages/design-system/src/components/N8nFormInput/validators.ts create mode 100644 packages/design-system/src/components/N8nFormInputs/FormInputs.stories.js create mode 100644 packages/design-system/src/components/N8nFormInputs/FormInputs.vue create mode 100644 packages/design-system/src/components/N8nFormInputs/index.js delete mode 100644 packages/design-system/src/components/N8nIcon/index.d.ts create mode 100644 packages/design-system/src/components/N8nLink/Link.stories.js create mode 100644 packages/design-system/src/components/N8nLink/Link.vue create mode 100644 packages/design-system/src/components/N8nLink/index.js create mode 100644 packages/design-system/src/components/N8nRoute/Route.vue create mode 100644 packages/design-system/src/components/N8nRoute/index.js create mode 100644 packages/design-system/src/components/N8nUserInfo/UserInfo.stories.js create mode 100644 packages/design-system/src/components/N8nUserInfo/UserInfo.vue create mode 100644 packages/design-system/src/components/N8nUserInfo/index.js create mode 100644 packages/design-system/src/components/N8nUserSelect/UserSelect.stories.js create mode 100644 packages/design-system/src/components/N8nUserSelect/UserSelect.vue create mode 100644 packages/design-system/src/components/N8nUserSelect/index.js create mode 100644 packages/design-system/src/components/N8nUsersList/UsersList.stories.js create mode 100644 packages/design-system/src/components/N8nUsersList/UsersList.vue create mode 100644 packages/design-system/src/components/N8nUsersList/index.js create mode 100644 packages/design-system/src/components/ResizeObserver/ResizeObserver.vue create mode 100644 packages/design-system/src/components/ResizeObserver/index.js create mode 100644 packages/design-system/src/locale/format.js create mode 100644 packages/design-system/src/locale/index.js create mode 100644 packages/design-system/src/locale/lang/en.js create mode 100644 packages/design-system/src/mixins/locale.js create mode 100644 packages/design-system/src/utils.scss create mode 100644 packages/editor-ui/public/n8n-logo.svg create mode 100644 packages/editor-ui/src/api/users.ts delete mode 100644 packages/editor-ui/src/components/About.vue create mode 100644 packages/editor-ui/src/components/AboutModal.vue create mode 100644 packages/editor-ui/src/components/ChangePasswordModal.vue create mode 100644 packages/editor-ui/src/components/DeleteUserModal.vue create mode 100644 packages/editor-ui/src/components/InviteUsersModal.vue create mode 100644 packages/editor-ui/src/components/Logo.vue rename packages/editor-ui/src/components/{MainSidebarMenuItemsIterator.vue => MenuItemsIterator.vue} (100%) create mode 100644 packages/editor-ui/src/components/PageAlert.vue create mode 100644 packages/editor-ui/src/components/SettingsSidebar.vue create mode 100644 packages/editor-ui/src/components/mixins/userHelpers.ts delete mode 100644 packages/editor-ui/src/modules/helper.ts create mode 100644 packages/editor-ui/src/modules/userHelpers.ts create mode 100644 packages/editor-ui/src/modules/users.ts create mode 100644 packages/editor-ui/src/views/AuthView.vue create mode 100644 packages/editor-ui/src/views/ChangePasswordView.vue create mode 100644 packages/editor-ui/src/views/ErrorView.vue create mode 100644 packages/editor-ui/src/views/ForgotMyPasswordView.vue create mode 100644 packages/editor-ui/src/views/SettingsPersonalView.vue create mode 100644 packages/editor-ui/src/views/SettingsUsersView.vue create mode 100644 packages/editor-ui/src/views/SettingsView.vue create mode 100644 packages/editor-ui/src/views/SetupView.vue create mode 100644 packages/editor-ui/src/views/SigninView.vue create mode 100644 packages/editor-ui/src/views/SignupView.vue delete mode 100644 packages/editor-ui/tests/unit/placeholder.spec.ts delete mode 100644 packages/nodes-base/test/placeholder.test.ts diff --git a/.eslintrc.js b/.eslintrc.js index 550426054b..2a9b045e34 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -257,6 +257,11 @@ module.exports = { */ '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }], + /** + * https://github.com/typescript-eslint/typescript-eslint/blob/v4.33.0/packages/eslint-plugin/docs/rules/no-namespace.md + */ + '@typescript-eslint/no-namespace': 'off', + /** * https://eslint.org/docs/1.0.0/rules/no-throw-literal */ diff --git a/package-lock.json b/package-lock.json index a7423ea693..cca5fe6745 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3030,37 +3030,49 @@ } }, "@jest/globals": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-26.6.2.tgz", - "integrity": "sha512-85Ltnm7HlB/KesBUuALwQ68YTU72w9H2xW9FjZ1eL1U3lhtefjjl5c2MiUbpXt/i6LaPRvoOFJ22yCBSfQ0JIA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz", + "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==", "requires": { - "@jest/environment": "^26.6.2", - "@jest/types": "^26.6.2", - "expect": "^26.6.2" + "@jest/environment": "^27.5.1", + "@jest/types": "^27.5.1", + "expect": "^27.5.1" }, "dependencies": { "@jest/environment": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-26.6.2.tgz", - "integrity": "sha512-nFy+fHl28zUrRsCeMB61VDThV1pVTtlEokBRgqPrcT1JNq4yRNIyTHfyht6PqtUvY9IsuLGTrbG8kPXjSZIZwA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", + "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", "requires": { - "@jest/fake-timers": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", "@types/node": "*", - "jest-mock": "^26.6.2" + "jest-mock": "^27.5.1" } }, "@jest/fake-timers": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-26.6.2.tgz", - "integrity": "sha512-14Uleatt7jdzefLPYM3KLcnUl1ZNikaKq34enpb5XG9i81JpppDb5muZvonvKyrl7ftEHkKS5L5/eB/kxJ+bvA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", + "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", "requires": { - "@jest/types": "^26.6.2", - "@sinonjs/fake-timers": "^6.0.1", + "@jest/types": "^27.5.1", + "@sinonjs/fake-timers": "^8.0.1", "@types/node": "*", - "jest-message-util": "^26.6.2", - "jest-mock": "^26.6.2", - "jest-util": "^26.6.2" + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + } + }, + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" } }, "@types/stack-utils": { @@ -3068,6 +3080,14 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "requires": { + "@types/yargs-parser": "*" + } + }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3085,6 +3105,11 @@ "supports-color": "^7.1.0" } }, + "ci-info": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz", + "integrity": "sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw==" + }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3104,16 +3129,14 @@ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==" }, "expect": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/expect/-/expect-26.6.2.tgz", - "integrity": "sha512-9/hlOBkQl2l/PLHJx6JjoDF6xPKcJEsUlWKb23rKE7KzeDqUZKXKNMW27KIue5JMdBV9HgmoJPcc8HtO85t9IA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", + "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", "requires": { - "@jest/types": "^26.6.2", - "ansi-styles": "^4.0.0", - "jest-get-type": "^26.3.0", - "jest-matcher-utils": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-regex-util": "^26.0.0" + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" } }, "has-flag": { @@ -3122,41 +3145,54 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, "jest-matcher-utils": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-26.6.2.tgz", - "integrity": "sha512-llnc8vQgYcNqDrqRDXWwMr9i7rS5XFiCwvh6DTP7Jqa2mqpcCBBlpCbn+trkG0KNhPu/h8rzyBkriOtBstvWhw==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", "requires": { "chalk": "^4.0.0", - "jest-diff": "^26.6.2", - "jest-get-type": "^26.3.0", - "pretty-format": "^26.6.2" + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" } }, "jest-message-util": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-26.6.2.tgz", - "integrity": "sha512-rGiLePzQ3AzwUshu2+Rn+UMFk0pHN58sOG+IaJbk5Jxuqo3NYO1U2/MIR4S1sKgsoYSXSzdtSa0TgrmtUwEbmA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", "requires": { - "@babel/code-frame": "^7.0.0", - "@jest/types": "^26.6.2", + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", - "graceful-fs": "^4.2.4", - "micromatch": "^4.0.2", - "pretty-format": "^26.6.2", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", "slash": "^3.0.0", - "stack-utils": "^2.0.2" + "stack-utils": "^2.0.3" } }, "jest-mock": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-26.6.2.tgz", - "integrity": "sha512-YyFjePHHp1LzpzYcmgqkJ0nm0gg/lJx2aZFzFy1S6eUqNjXsOqTK10zNRff2dNfssgokjkG65OlWNcIlgd3zew==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", "requires": { - "@jest/types": "^26.6.2", + "@jest/types": "^27.5.1", "@types/node": "*" } }, + "jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "requires": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + } + }, "stack-utils": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.5.tgz", @@ -10340,9 +10376,9 @@ } }, "@sinonjs/fake-timers": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz", - "integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", + "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", "requires": { "@sinonjs/commons": "^1.7.0" } @@ -12370,6 +12406,19 @@ "resolved": "https://registry.npmjs.org/@types/convict/-/convict-4.2.1.tgz", "integrity": "sha512-2cd51m3i0yeY1i3dKxcqJKeS5Q4jZnjP37OseoNeIX1OM0AhmGPuuYmwJ9OqtsU35YrREQxdb2VeX5sM3cwGMQ==" }, + "@types/cookie-parser": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.2.tgz", + "integrity": "sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg==", + "requires": { + "@types/express": "*" + } + }, + "@types/cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==" + }, "@types/copyfiles": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@types/copyfiles/-/copyfiles-2.4.1.tgz", @@ -12573,12 +12622,68 @@ } }, "@types/jest": { - "version": "26.0.24", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.24.tgz", - "integrity": "sha512-E/X5Vib8BWqZNRlDxj9vYXhsDwPYbPINqKF9BsnSoon4RQ0D9moEuLD8txgyypFLH7J4+Lho9Nr/c8H0Fi+17w==", + "version": "27.4.1", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.4.1.tgz", + "integrity": "sha512-23iPJADSmicDVrWk+HT58LMJtzLAnB2AgIzplQuq/bSrGaxCrlvRFjGbXmamnnk/mAmCdLStiGqggu28ocUyiw==", "requires": { - "jest-diff": "^26.0.0", - "pretty-format": "^26.0.0" + "jest-matcher-utils": "^27.0.0", + "pretty-format": "^27.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "jest-matcher-utils": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "requires": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + } } }, "@types/jmespath": { @@ -12825,6 +12930,33 @@ "@types/node": "*" } }, + "@types/passport": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.7.tgz", + "integrity": "sha512-JtswU8N3kxBYgo+n9of7C97YQBT+AYPP2aBfNGTzABqPAZnK/WOAaKfh3XesUYMZRrXFuoPc2Hv0/G/nQFveHw==", + "requires": { + "@types/express": "*" + } + }, + "@types/passport-jwt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.6.tgz", + "integrity": "sha512-cmAAMIRTaEwpqxlrZyiEY9kdibk94gP5KTF8AT1Ra4rWNZYHNMreqhKUEeC5WJtuN5SJZjPQmV+XO2P5PlnvNQ==", + "requires": { + "@types/express": "*", + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "@types/passport-strategy": { + "version": "0.2.35", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz", + "integrity": "sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==", + "requires": { + "@types/express": "*", + "@types/passport": "*" + } + }, "@types/prettier": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.4.tgz", @@ -13020,6 +13152,23 @@ "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==" }, + "@types/superagent": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.13.tgz", + "integrity": "sha512-YIGelp3ZyMiH0/A09PMAORO0EBGlF5xIKfDpK74wdYvWUs2o96b5CItJcWPdH409b7SAXIIG6p8NdU/4U2Maww==", + "requires": { + "@types/cookiejar": "*", + "@types/node": "*" + } + }, + "@types/supertest": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.11.tgz", + "integrity": "sha512-uci4Esokrw9qGb9bvhhSVEjd6rkny/dk5PK/Qz4yxKiyppEI+dOPlNrZBahE3i+PoKFYyDxChVXZ/ysS/nrm1Q==", + "requires": { + "@types/superagent": "*" + } + }, "@types/tapable": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.8.tgz", @@ -13687,6 +13836,15 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz", "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==" }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "optional": true, + "requires": { + "color-convert": "^2.0.1" + } + }, "array-union": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", @@ -13722,6 +13880,21 @@ } } }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "optional": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "optional": true + }, "commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -13784,6 +13957,58 @@ "worker-rpc": "^0.1.0" } }, + "fork-ts-checker-webpack-plugin-v5": { + "version": "npm:fork-ts-checker-webpack-plugin@5.2.1", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.1.tgz", + "integrity": "sha512-SVi+ZAQOGbtAsUWrZvGzz38ga2YqjWvca1pXQFUArIVXqli0lLoDQ8uS0wg0kSpcwpZmaW5jVCZXQebkyUQSsw==", + "optional": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" + }, + "dependencies": { + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "optional": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "optional": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "optional": true, + "requires": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, "glob-parent": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", @@ -13818,6 +14043,12 @@ "slash": "^2.0.0" } }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "optional": true + }, "ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", @@ -13846,6 +14077,16 @@ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" }, + "jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "optional": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + } + }, "micromatch": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", @@ -13881,6 +14122,17 @@ } } }, + "schema-utils": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", + "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "optional": true, + "requires": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + } + }, "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", @@ -13891,6 +14143,15 @@ "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==" }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "optional": true, + "requires": { + "has-flag": "^4.0.0" + } + }, "to-regex-range": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", @@ -13984,6 +14245,12 @@ "requires": { "tslib": "^1.8.1" } + }, + "universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "optional": true } } }, @@ -17984,9 +18251,14 @@ } }, "cjs-module-lexer": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-0.6.0.tgz", - "integrity": "sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw==" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz", + "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==" + }, + "clamp": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz", + "integrity": "sha1-ZqDmQBGBbjcZaCj9yMjBRzEshjQ=" }, "class-utils": { "version": "0.3.6", @@ -19480,11 +19752,32 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==" }, + "cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "requires": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "dependencies": { + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + } + } + }, "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" }, + "cookiejar": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz", + "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==" + }, "copy-concurrently": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz", @@ -20595,8 +20888,7 @@ "dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", - "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=", - "dev": true + "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=" }, "deep-equal": { "version": "1.1.1", @@ -20967,7 +21259,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.3.tgz", "integrity": "sha1-f3Qt4Gb8dIvI24IFad3c5Jvw1FY=", - "dev": true, "requires": { "asap": "^2.0.0", "wrappy": "1" @@ -21007,9 +21298,9 @@ "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==" }, "diff-sequences": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", - "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==" + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", + "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==" }, "diffie-hellman": { "version": "5.0.3", @@ -21413,9 +21704,9 @@ } }, "emittery": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.7.2.tgz", - "integrity": "sha512-A8OG5SR/ij3SsJdWDJdkkSYUjQdCUx6APQXem0SaEePBSRg4eymGYwBkKo1Y6DU+af/Jn2dBQqDBvjnr9Vi8nQ==" + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz", + "integrity": "sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==" }, "emoji-regex": { "version": "8.0.0", @@ -22816,6 +23107,11 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" }, + "fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + }, "fastq": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", @@ -23462,124 +23758,6 @@ } } }, - "fork-ts-checker-webpack-plugin-v5": { - "version": "npm:fork-ts-checker-webpack-plugin@5.2.1", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.1.tgz", - "integrity": "sha512-SVi+ZAQOGbtAsUWrZvGzz38ga2YqjWvca1pXQFUArIVXqli0lLoDQ8uS0wg0kSpcwpZmaW5jVCZXQebkyUQSsw==", - "optional": true, - "requires": { - "@babel/code-frame": "^7.8.3", - "@types/json-schema": "^7.0.5", - "chalk": "^4.1.0", - "cosmiconfig": "^6.0.0", - "deepmerge": "^4.2.2", - "fs-extra": "^9.0.0", - "memfs": "^3.1.2", - "minimatch": "^3.0.4", - "schema-utils": "2.7.0", - "semver": "^7.3.2", - "tapable": "^1.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "optional": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "optional": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "optional": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "optional": true - }, - "fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "optional": true, - "requires": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - } - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "optional": true - }, - "jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "optional": true, - "requires": { - "graceful-fs": "^4.1.6", - "universalify": "^2.0.0" - } - }, - "schema-utils": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", - "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", - "optional": true, - "requires": { - "@types/json-schema": "^7.0.4", - "ajv": "^6.12.2", - "ajv-keywords": "^3.4.1" - } - }, - "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "optional": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "optional": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "optional": true - } - } - }, "form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -25874,6 +26052,11 @@ "resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz", "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==" }, + "hexoid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", + "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==" + }, "highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -27460,150 +27643,183 @@ "integrity": "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==" }, "jest": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest/-/jest-26.6.3.tgz", - "integrity": "sha512-lGS5PXGAzR4RF7V5+XObhqz2KZIDUA1yD0DG6pBVmy10eh0ZIXQImRuzocsI/N2XZ1GrLFwTS27In2i2jlpq1Q==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", + "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", "requires": { - "@jest/core": "^26.6.3", + "@jest/core": "^27.5.1", "import-local": "^3.0.2", - "jest-cli": "^26.6.3" + "jest-cli": "^27.5.1" }, "dependencies": { "@jest/console": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-26.6.2.tgz", - "integrity": "sha512-IY1R2i2aLsLr7Id3S6p2BA82GNWryt4oSvEXLAKc+L2zdi89dSkE8xC1C+0kpATG4JhBJREnQOH7/zmccM2B0g==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", + "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", "requires": { - "@jest/types": "^26.6.2", + "@jest/types": "^27.5.1", "@types/node": "*", "chalk": "^4.0.0", - "jest-message-util": "^26.6.2", - "jest-util": "^26.6.2", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", "slash": "^3.0.0" } }, "@jest/core": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-26.6.3.tgz", - "integrity": "sha512-xvV1kKbhfUqFVuZ8Cyo+JPpipAHHAV3kcDBftiduK8EICXmTFddryy3P7NfZt8Pv37rA9nEJBKCCkglCPt/Xjw==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-27.5.1.tgz", + "integrity": "sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ==", "requires": { - "@jest/console": "^26.6.2", - "@jest/reporters": "^26.6.2", - "@jest/test-result": "^26.6.2", - "@jest/transform": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/console": "^27.5.1", + "@jest/reporters": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", "@types/node": "*", "ansi-escapes": "^4.2.1", "chalk": "^4.0.0", + "emittery": "^0.8.1", "exit": "^0.1.2", - "graceful-fs": "^4.2.4", - "jest-changed-files": "^26.6.2", - "jest-config": "^26.6.3", - "jest-haste-map": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-regex-util": "^26.0.0", - "jest-resolve": "^26.6.2", - "jest-resolve-dependencies": "^26.6.3", - "jest-runner": "^26.6.3", - "jest-runtime": "^26.6.3", - "jest-snapshot": "^26.6.2", - "jest-util": "^26.6.2", - "jest-validate": "^26.6.2", - "jest-watcher": "^26.6.2", - "micromatch": "^4.0.2", - "p-each-series": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^27.5.1", + "jest-config": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-resolve-dependencies": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "jest-watcher": "^27.5.1", + "micromatch": "^4.0.4", "rimraf": "^3.0.0", "slash": "^3.0.0", "strip-ansi": "^6.0.0" } }, "@jest/environment": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-26.6.2.tgz", - "integrity": "sha512-nFy+fHl28zUrRsCeMB61VDThV1pVTtlEokBRgqPrcT1JNq4yRNIyTHfyht6PqtUvY9IsuLGTrbG8kPXjSZIZwA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", + "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", "requires": { - "@jest/fake-timers": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", "@types/node": "*", - "jest-mock": "^26.6.2" + "jest-mock": "^27.5.1" } }, "@jest/fake-timers": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-26.6.2.tgz", - "integrity": "sha512-14Uleatt7jdzefLPYM3KLcnUl1ZNikaKq34enpb5XG9i81JpppDb5muZvonvKyrl7ftEHkKS5L5/eB/kxJ+bvA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", + "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", "requires": { - "@jest/types": "^26.6.2", - "@sinonjs/fake-timers": "^6.0.1", + "@jest/types": "^27.5.1", + "@sinonjs/fake-timers": "^8.0.1", "@types/node": "*", - "jest-message-util": "^26.6.2", - "jest-mock": "^26.6.2", - "jest-util": "^26.6.2" + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" } }, "@jest/reporters": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-26.6.2.tgz", - "integrity": "sha512-h2bW53APG4HvkOnVMo8q3QXa6pcaNt1HkwVsOPMBV6LD/q9oSpxNSYZQYkAnjdMjrJ86UuYeLo+aEZClV6opnw==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-27.5.1.tgz", + "integrity": "sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw==", "requires": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^26.6.2", - "@jest/test-result": "^26.6.2", - "@jest/transform": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/console": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", "chalk": "^4.0.0", "collect-v8-coverage": "^1.0.0", "exit": "^0.1.2", "glob": "^7.1.2", - "graceful-fs": "^4.2.4", + "graceful-fs": "^4.2.9", "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^4.0.3", + "istanbul-lib-instrument": "^5.1.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.0.2", - "jest-haste-map": "^26.6.2", - "jest-resolve": "^26.6.2", - "jest-util": "^26.6.2", - "jest-worker": "^26.6.2", - "node-notifier": "^8.0.0", + "istanbul-reports": "^3.1.3", + "jest-haste-map": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", "slash": "^3.0.0", "source-map": "^0.6.0", "string-length": "^4.0.1", "terminal-link": "^2.0.0", - "v8-to-istanbul": "^7.0.0" + "v8-to-istanbul": "^8.1.0" } }, "@jest/source-map": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-26.6.2.tgz", - "integrity": "sha512-YwYcCwAnNmOVsZ8mr3GfnzdXDAl4LaenZP5z+G0c8bzC9/dugL8zRmxZzdoTl4IaS3CryS1uWnROLPFmb6lVvA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", + "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", "requires": { "callsites": "^3.0.0", - "graceful-fs": "^4.2.4", + "graceful-fs": "^4.2.9", "source-map": "^0.6.0" } }, "@jest/test-result": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-26.6.2.tgz", - "integrity": "sha512-5O7H5c/7YlojphYNrK02LlDIV2GNPYisKwHm2QTKjNZeEzezCbwYs9swJySv2UfPMyZ0VdsmMv7jIlD/IKYQpQ==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", + "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", "requires": { - "@jest/console": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/console": "^27.5.1", + "@jest/types": "^27.5.1", "@types/istanbul-lib-coverage": "^2.0.0", "collect-v8-coverage": "^1.0.0" } }, "@jest/test-sequencer": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-26.6.3.tgz", - "integrity": "sha512-YHlVIjP5nfEyjlrSr8t/YdNfU/1XEt7c5b4OxcXCjyRhjzLYu/rO69/WHPuYcbCWkz8kAeZVZp2N2+IOLLEPGw==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz", + "integrity": "sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ==", "requires": { - "@jest/test-result": "^26.6.2", - "graceful-fs": "^4.2.4", - "jest-haste-map": "^26.6.2", - "jest-runner": "^26.6.3", - "jest-runtime": "^26.6.3" + "@jest/test-result": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-runtime": "^27.5.1" + } + }, + "@jest/transform": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", + "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", + "requires": { + "@babel/core": "^7.1.0", + "@jest/types": "^27.5.1", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-util": "^27.5.1", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "^3.0.0" + } + }, + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" } }, "@types/stack-utils": { @@ -27611,6 +27827,14 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "requires": { + "@types/yargs-parser": "*" + } + }, "acorn": { "version": "8.7.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", @@ -27641,24 +27865,24 @@ } }, "babel-jest": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-26.6.3.tgz", - "integrity": "sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", + "integrity": "sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==", "requires": { - "@jest/transform": "^26.6.2", - "@jest/types": "^26.6.2", - "@types/babel__core": "^7.1.7", - "babel-plugin-istanbul": "^6.0.0", - "babel-preset-jest": "^26.6.2", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^27.5.1", "chalk": "^4.0.0", - "graceful-fs": "^4.2.4", + "graceful-fs": "^4.2.9", "slash": "^3.0.0" } }, "babel-plugin-jest-hoist": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.6.2.tgz", - "integrity": "sha512-PO9t0697lNTmcEHH69mdtYiOIkkOlj9fySqfO3K1eCcdISevLAE0xY59VLLUj0SoiPiTX/JU2CYFpILydUa5Lw==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.5.1.tgz", + "integrity": "sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ==", "requires": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", @@ -27667,11 +27891,11 @@ } }, "babel-preset-jest": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-26.6.2.tgz", - "integrity": "sha512-YvdtlVm9t3k777c5NPQIv6cxFFFapys25HiUmuSgHwIZhfifweR5c5Sf5nwE3MAbfu327CYSvps8Yx6ANLyleQ==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-27.5.1.tgz", + "integrity": "sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag==", "requires": { - "babel-plugin-jest-hoist": "^26.6.2", + "babel-plugin-jest-hoist": "^27.5.1", "babel-preset-current-node-syntax": "^1.0.0" } }, @@ -27689,14 +27913,19 @@ "supports-color": "^7.1.0" } }, + "ci-info": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz", + "integrity": "sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw==" + }, "cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "requires": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" + "wrap-ansi": "^7.0.0" } }, "color-convert": { @@ -27778,32 +28007,30 @@ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==" }, "execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "requires": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "expect": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/expect/-/expect-26.6.2.tgz", - "integrity": "sha512-9/hlOBkQl2l/PLHJx6JjoDF6xPKcJEsUlWKb23rKE7KzeDqUZKXKNMW27KIue5JMdBV9HgmoJPcc8HtO85t9IA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", + "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", "requires": { - "@jest/types": "^26.6.2", - "ansi-styles": "^4.0.0", - "jest-get-type": "^26.3.0", - "jest-matcher-utils": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-regex-util": "^26.0.0" + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" } }, "form-data": { @@ -27816,6 +28043,11 @@ "mime-types": "^2.1.12" } }, + "get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==" + }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -27829,6 +28061,11 @@ "whatwg-encoding": "^1.0.5" } }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==" + }, "import-local": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", @@ -27843,17 +28080,6 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" }, - "istanbul-lib-instrument": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", - "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", - "requires": { - "@babel/core": "^7.7.5", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.0.0", - "semver": "^6.3.0" - } - }, "istanbul-lib-report": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", @@ -27884,323 +28110,388 @@ } }, "jest-changed-files": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.6.2.tgz", - "integrity": "sha512-fDS7szLcY9sCtIip8Fjry9oGf3I2ht/QT21bAHm5Dmf0mD4X3ReNUf17y+bO6fR8WgbIZTlbyG1ak/53cbRzKQ==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz", + "integrity": "sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==", "requires": { - "@jest/types": "^26.6.2", - "execa": "^4.0.0", - "throat": "^5.0.0" + "@jest/types": "^27.5.1", + "execa": "^5.0.0", + "throat": "^6.0.1" } }, "jest-cli": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-26.6.3.tgz", - "integrity": "sha512-GF9noBSa9t08pSyl3CY4frMrqp+aQXFGFkf5hEPbh/pIUFYWMK6ZLTfbmadxJVcJrdRoChlWQsA2VkJcDFK8hg==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-27.5.1.tgz", + "integrity": "sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw==", "requires": { - "@jest/core": "^26.6.3", - "@jest/test-result": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/core": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", "chalk": "^4.0.0", "exit": "^0.1.2", - "graceful-fs": "^4.2.4", + "graceful-fs": "^4.2.9", "import-local": "^3.0.2", - "is-ci": "^2.0.0", - "jest-config": "^26.6.3", - "jest-util": "^26.6.2", - "jest-validate": "^26.6.2", + "jest-config": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", "prompts": "^2.0.1", - "yargs": "^15.4.1" + "yargs": "^16.2.0" } }, "jest-config": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-26.6.3.tgz", - "integrity": "sha512-t5qdIj/bCj2j7NFVHb2nFB4aUdfucDn3JRKgrZnplb8nieAirAzRSHP8uDEd+qV6ygzg9Pz4YG7UTJf94LPSyg==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-27.5.1.tgz", + "integrity": "sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA==", "requires": { - "@babel/core": "^7.1.0", - "@jest/test-sequencer": "^26.6.3", - "@jest/types": "^26.6.2", - "babel-jest": "^26.6.3", + "@babel/core": "^7.8.0", + "@jest/test-sequencer": "^27.5.1", + "@jest/types": "^27.5.1", + "babel-jest": "^27.5.1", "chalk": "^4.0.0", + "ci-info": "^3.2.0", "deepmerge": "^4.2.2", "glob": "^7.1.1", - "graceful-fs": "^4.2.4", - "jest-environment-jsdom": "^26.6.2", - "jest-environment-node": "^26.6.2", - "jest-get-type": "^26.3.0", - "jest-jasmine2": "^26.6.3", - "jest-regex-util": "^26.0.0", - "jest-resolve": "^26.6.2", - "jest-util": "^26.6.2", - "jest-validate": "^26.6.2", - "micromatch": "^4.0.2", - "pretty-format": "^26.6.2" + "graceful-fs": "^4.2.9", + "jest-circus": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-jasmine2": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" } }, "jest-docblock": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-26.0.0.tgz", - "integrity": "sha512-RDZ4Iz3QbtRWycd8bUEPxQsTlYazfYn/h5R65Fc6gOfwozFhoImx+affzky/FFBuqISPTqjXomoIGJVKBWoo0w==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-27.5.1.tgz", + "integrity": "sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ==", "requires": { "detect-newline": "^3.0.0" } }, "jest-each": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-26.6.2.tgz", - "integrity": "sha512-Mer/f0KaATbjl8MCJ+0GEpNdqmnVmDYqCTJYTvoo7rqmRiDllmp2AYN+06F93nXcY3ur9ShIjS+CO/uD+BbH4A==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", + "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", "requires": { - "@jest/types": "^26.6.2", + "@jest/types": "^27.5.1", "chalk": "^4.0.0", - "jest-get-type": "^26.3.0", - "jest-util": "^26.6.2", - "pretty-format": "^26.6.2" + "jest-get-type": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1" } }, "jest-environment-jsdom": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-26.6.2.tgz", - "integrity": "sha512-jgPqCruTlt3Kwqg5/WVFyHIOJHsiAvhcp2qiR2QQstuG9yWox5+iHpU3ZrcBxW14T4fe5Z68jAfLRh7joCSP2Q==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz", + "integrity": "sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==", "requires": { - "@jest/environment": "^26.6.2", - "@jest/fake-timers": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", "@types/node": "*", - "jest-mock": "^26.6.2", - "jest-util": "^26.6.2", - "jsdom": "^16.4.0" + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1", + "jsdom": "^16.6.0" } }, "jest-environment-node": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-26.6.2.tgz", - "integrity": "sha512-zhtMio3Exty18dy8ee8eJ9kjnRyZC1N4C1Nt/VShN1apyXc8rWGtJ9lI7vqiWcyyXS4BVSEn9lxAM2D+07/Tag==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.5.1.tgz", + "integrity": "sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==", "requires": { - "@jest/environment": "^26.6.2", - "@jest/fake-timers": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", "@types/node": "*", - "jest-mock": "^26.6.2", - "jest-util": "^26.6.2" + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + } + }, + "jest-haste-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz", + "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==", + "requires": { + "@jest/types": "^27.5.1", + "@types/graceful-fs": "^4.1.2", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "fsevents": "^2.3.2", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^27.5.1", + "jest-serializer": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "micromatch": "^4.0.4", + "walker": "^1.0.7" } }, "jest-jasmine2": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-26.6.3.tgz", - "integrity": "sha512-kPKUrQtc8aYwBV7CqBg5pu+tmYXlvFlSFYn18ev4gPFtrRzB15N2gW/Roew3187q2w2eHuu0MU9TJz6w0/nPEg==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz", + "integrity": "sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==", "requires": { - "@babel/traverse": "^7.1.0", - "@jest/environment": "^26.6.2", - "@jest/source-map": "^26.6.2", - "@jest/test-result": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/environment": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", "@types/node": "*", "chalk": "^4.0.0", "co": "^4.6.0", - "expect": "^26.6.2", + "expect": "^27.5.1", "is-generator-fn": "^2.0.0", - "jest-each": "^26.6.2", - "jest-matcher-utils": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-runtime": "^26.6.3", - "jest-snapshot": "^26.6.2", - "jest-util": "^26.6.2", - "pretty-format": "^26.6.2", - "throat": "^5.0.0" + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "throat": "^6.0.1" } }, "jest-leak-detector": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-26.6.2.tgz", - "integrity": "sha512-i4xlXpsVSMeKvg2cEKdfhh0H39qlJlP5Ex1yQxwF9ubahboQYMgTtz5oML35AVA3B4Eu+YsmwaiKVev9KCvLxg==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz", + "integrity": "sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ==", "requires": { - "jest-get-type": "^26.3.0", - "pretty-format": "^26.6.2" + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" } }, "jest-matcher-utils": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-26.6.2.tgz", - "integrity": "sha512-llnc8vQgYcNqDrqRDXWwMr9i7rS5XFiCwvh6DTP7Jqa2mqpcCBBlpCbn+trkG0KNhPu/h8rzyBkriOtBstvWhw==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", "requires": { "chalk": "^4.0.0", - "jest-diff": "^26.6.2", - "jest-get-type": "^26.3.0", - "pretty-format": "^26.6.2" + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" } }, "jest-message-util": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-26.6.2.tgz", - "integrity": "sha512-rGiLePzQ3AzwUshu2+Rn+UMFk0pHN58sOG+IaJbk5Jxuqo3NYO1U2/MIR4S1sKgsoYSXSzdtSa0TgrmtUwEbmA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", "requires": { - "@babel/code-frame": "^7.0.0", - "@jest/types": "^26.6.2", + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", - "graceful-fs": "^4.2.4", - "micromatch": "^4.0.2", - "pretty-format": "^26.6.2", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", "slash": "^3.0.0", - "stack-utils": "^2.0.2" + "stack-utils": "^2.0.3" } }, "jest-mock": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-26.6.2.tgz", - "integrity": "sha512-YyFjePHHp1LzpzYcmgqkJ0nm0gg/lJx2aZFzFy1S6eUqNjXsOqTK10zNRff2dNfssgokjkG65OlWNcIlgd3zew==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", "requires": { - "@jest/types": "^26.6.2", + "@jest/types": "^27.5.1", "@types/node": "*" } }, + "jest-regex-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", + "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==" + }, "jest-resolve": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-26.6.2.tgz", - "integrity": "sha512-sOxsZOq25mT1wRsfHcbtkInS+Ek7Q8jCHUB0ZUTP0tc/c41QHriU/NunqMfCUWsL4H3MHpvQD4QR9kSYhS7UvQ==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz", + "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==", "requires": { - "@jest/types": "^26.6.2", + "@jest/types": "^27.5.1", "chalk": "^4.0.0", - "graceful-fs": "^4.2.4", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", "jest-pnp-resolver": "^1.2.2", - "jest-util": "^26.6.2", - "read-pkg-up": "^7.0.1", - "resolve": "^1.18.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "resolve": "^1.20.0", + "resolve.exports": "^1.1.0", "slash": "^3.0.0" } }, "jest-resolve-dependencies": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-26.6.3.tgz", - "integrity": "sha512-pVwUjJkxbhe4RY8QEWzN3vns2kqyuldKpxlxJlzEYfKSvY6/bMvxoFrYYzUO1Gx28yKWN37qyV7rIoIp2h8fTg==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz", + "integrity": "sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg==", "requires": { - "@jest/types": "^26.6.2", - "jest-regex-util": "^26.0.0", - "jest-snapshot": "^26.6.2" + "@jest/types": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-snapshot": "^27.5.1" } }, "jest-runner": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-26.6.3.tgz", - "integrity": "sha512-atgKpRHnaA2OvByG/HpGA4g6CSPS/1LK0jK3gATJAoptC1ojltpmVlYC3TYgdmGp+GLuhzpH30Gvs36szSL2JQ==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-27.5.1.tgz", + "integrity": "sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ==", "requires": { - "@jest/console": "^26.6.2", - "@jest/environment": "^26.6.2", - "@jest/test-result": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/console": "^27.5.1", + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", "@types/node": "*", "chalk": "^4.0.0", - "emittery": "^0.7.1", - "exit": "^0.1.2", - "graceful-fs": "^4.2.4", - "jest-config": "^26.6.3", - "jest-docblock": "^26.0.0", - "jest-haste-map": "^26.6.2", - "jest-leak-detector": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-resolve": "^26.6.2", - "jest-runtime": "^26.6.3", - "jest-util": "^26.6.2", - "jest-worker": "^26.6.2", + "emittery": "^0.8.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-leak-detector": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", "source-map-support": "^0.5.6", - "throat": "^5.0.0" + "throat": "^6.0.1" } }, "jest-runtime": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-26.6.3.tgz", - "integrity": "sha512-lrzyR3N8sacTAMeonbqpnSka1dHNux2uk0qqDXVkMv2c/A3wYnvQ4EXuI013Y6+gSKSCxdaczvf4HF0mVXHRdw==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", + "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", "requires": { - "@jest/console": "^26.6.2", - "@jest/environment": "^26.6.2", - "@jest/fake-timers": "^26.6.2", - "@jest/globals": "^26.6.2", - "@jest/source-map": "^26.6.2", - "@jest/test-result": "^26.6.2", - "@jest/transform": "^26.6.2", - "@jest/types": "^26.6.2", - "@types/yargs": "^15.0.0", + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/globals": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", "chalk": "^4.0.0", - "cjs-module-lexer": "^0.6.0", + "cjs-module-lexer": "^1.0.0", "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", + "execa": "^5.0.0", "glob": "^7.1.3", - "graceful-fs": "^4.2.4", - "jest-config": "^26.6.3", - "jest-haste-map": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-mock": "^26.6.2", - "jest-regex-util": "^26.0.0", - "jest-resolve": "^26.6.2", - "jest-snapshot": "^26.6.2", - "jest-util": "^26.6.2", - "jest-validate": "^26.6.2", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", "slash": "^3.0.0", - "strip-bom": "^4.0.0", - "yargs": "^15.4.1" + "strip-bom": "^4.0.0" + } + }, + "jest-serializer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", + "integrity": "sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==", + "requires": { + "@types/node": "*", + "graceful-fs": "^4.2.9" } }, "jest-snapshot": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-26.6.2.tgz", - "integrity": "sha512-OLhxz05EzUtsAmOMzuupt1lHYXCNib0ECyuZ/PZOx9TrZcC8vL0x+DUG3TL+GLX3yHG45e6YGjIm0XwDc3q3og==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", + "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", "requires": { + "@babel/core": "^7.7.2", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/traverse": "^7.7.2", "@babel/types": "^7.0.0", - "@jest/types": "^26.6.2", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", "@types/babel__traverse": "^7.0.4", - "@types/prettier": "^2.0.0", + "@types/prettier": "^2.1.5", + "babel-preset-current-node-syntax": "^1.0.0", "chalk": "^4.0.0", - "expect": "^26.6.2", - "graceful-fs": "^4.2.4", - "jest-diff": "^26.6.2", - "jest-get-type": "^26.3.0", - "jest-haste-map": "^26.6.2", - "jest-matcher-utils": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-resolve": "^26.6.2", + "expect": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", "natural-compare": "^1.4.0", - "pretty-format": "^26.6.2", + "pretty-format": "^27.5.1", "semver": "^7.3.2" - }, - "dependencies": { - "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "requires": { - "lru-cache": "^6.0.0" - } - } + } + }, + "jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "requires": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" } }, "jest-validate": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-26.6.2.tgz", - "integrity": "sha512-NEYZ9Aeyj0i5rQqbq+tpIOom0YS1u2MVu6+euBsvpgIme+FOfRmoC4R5p0JiAUpaFvFy24xgrpMknarR/93XjQ==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", + "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", "requires": { - "@jest/types": "^26.6.2", - "camelcase": "^6.0.0", + "@jest/types": "^27.5.1", + "camelcase": "^6.2.0", "chalk": "^4.0.0", - "jest-get-type": "^26.3.0", + "jest-get-type": "^27.5.1", "leven": "^3.1.0", - "pretty-format": "^26.6.2" + "pretty-format": "^27.5.1" } }, "jest-watcher": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-26.6.2.tgz", - "integrity": "sha512-WKJob0P/Em2csiVthsI68p6aGKTIcsfjH9Gsx1f0A3Italz43e3ho0geSAVsmj09RWOELP1AZ/DXyJgOgDKxXQ==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-27.5.1.tgz", + "integrity": "sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw==", "requires": { - "@jest/test-result": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", "@types/node": "*", "ansi-escapes": "^4.2.1", "chalk": "^4.0.0", - "jest-util": "^26.6.2", + "jest-util": "^27.5.1", "string-length": "^4.0.1" } }, + "jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "dependencies": { + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "jsdom": { "version": "16.7.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", @@ -28245,31 +28536,6 @@ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" }, - "node-notifier": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.2.tgz", - "integrity": "sha512-oJP/9NAdd9+x2Q+rfphB2RJCHjod70RcRLjosiPMMu5gjIfwVnOUGq2nbTjTUbmy0DJ/tFIVT30+Qe3nzl4TJg==", - "optional": true, - "requires": { - "growly": "^1.3.0", - "is-wsl": "^2.2.0", - "semver": "^7.3.2", - "shellwords": "^0.1.1", - "uuid": "^8.3.0", - "which": "^2.0.2" - }, - "dependencies": { - "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "optional": true, - "requires": { - "lru-cache": "^6.0.0" - } - } - } - }, "npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -28286,10 +28552,16 @@ "mimic-fn": "^2.1.0" } }, - "p-each-series": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.2.0.tgz", - "integrity": "sha512-ycIL2+1V32th+8scbpTvyHNaHe02z0sjgh91XXjAk+ZeXoPN4Z46DVUnzdso0aX4KckKw0FNNFHdjZ2UsZvxiA==" + "parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } }, "path-key": { "version": "3.1.1", @@ -28312,6 +28584,14 @@ "xmlchars": "^2.2.0" } }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "requires": { + "lru-cache": "^6.0.0" + } + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -28347,6 +28627,11 @@ "strip-ansi": "^6.0.0" } }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" + }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -28356,9 +28641,9 @@ } }, "throat": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", - "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==" + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.1.tgz", + "integrity": "sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==" }, "tough-cookie": { "version": "4.0.0", @@ -28409,54 +28694,34 @@ "isexe": "^2.0.0" } }, - "wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - }, "ws": { "version": "7.5.7", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==" }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" + }, "yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "requires": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" } }, "yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "requires": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "dependencies": { - "camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" - } - } + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==" } } }, @@ -28521,6 +28786,530 @@ } } }, + "jest-circus": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-27.5.1.tgz", + "integrity": "sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw==", + "requires": { + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^0.7.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3", + "throat": "^6.0.1" + }, + "dependencies": { + "@jest/console": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", + "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", + "requires": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0" + } + }, + "@jest/environment": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", + "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", + "requires": { + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1" + } + }, + "@jest/fake-timers": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", + "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", + "requires": { + "@jest/types": "^27.5.1", + "@sinonjs/fake-timers": "^8.0.1", + "@types/node": "*", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + } + }, + "@jest/source-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", + "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", + "requires": { + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9", + "source-map": "^0.6.0" + } + }, + "@jest/test-result": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", + "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", + "requires": { + "@jest/console": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + } + }, + "@jest/transform": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", + "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", + "requires": { + "@babel/core": "^7.1.0", + "@jest/types": "^27.5.1", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-util": "^27.5.1", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "^3.0.0" + } + }, + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/stack-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", + "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==" + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "ci-info": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz", + "integrity": "sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw==" + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==" + }, + "execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + } + }, + "expect": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", + "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", + "requires": { + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" + } + }, + "get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==" + }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" + }, + "jest-each": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", + "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", + "requires": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1" + } + }, + "jest-haste-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz", + "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==", + "requires": { + "@jest/types": "^27.5.1", + "@types/graceful-fs": "^4.1.2", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "fsevents": "^2.3.2", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^27.5.1", + "jest-serializer": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "micromatch": "^4.0.4", + "walker": "^1.0.7" + } + }, + "jest-matcher-utils": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "requires": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + } + }, + "jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "requires": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + } + }, + "jest-mock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", + "requires": { + "@jest/types": "^27.5.1", + "@types/node": "*" + } + }, + "jest-regex-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", + "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==" + }, + "jest-resolve": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz", + "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==", + "requires": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "resolve": "^1.20.0", + "resolve.exports": "^1.1.0", + "slash": "^3.0.0" + } + }, + "jest-runtime": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", + "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", + "requires": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/globals": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "execa": "^5.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + } + }, + "jest-serializer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", + "integrity": "sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==", + "requires": { + "@types/node": "*", + "graceful-fs": "^4.2.9" + } + }, + "jest-snapshot": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", + "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", + "requires": { + "@babel/core": "^7.7.2", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/traverse": "^7.7.2", + "@babel/types": "^7.0.0", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__traverse": "^7.0.4", + "@types/prettier": "^2.1.5", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "natural-compare": "^1.4.0", + "pretty-format": "^27.5.1", + "semver": "^7.3.2" + } + }, + "jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "requires": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + } + }, + "jest-validate": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", + "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", + "requires": { + "@jest/types": "^27.5.1", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "leven": "^3.1.0", + "pretty-format": "^27.5.1" + } + }, + "jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "dependencies": { + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" + }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "requires": { + "path-key": "^3.0.0" + } + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "requires": { + "lru-cache": "^6.0.0" + } + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "stack-utils": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.5.tgz", + "integrity": "sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA==", + "requires": { + "escape-string-regexp": "^2.0.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + }, + "throat": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.1.tgz", + "integrity": "sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==" + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "requires": { + "isexe": "^2.0.0" + } + } + } + }, "jest-config": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-24.9.0.tgz", @@ -28730,14 +29519,14 @@ } }, "jest-diff": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz", - "integrity": "sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", "requires": { "chalk": "^4.0.0", - "diff-sequences": "^26.6.2", - "jest-get-type": "^26.3.0", - "pretty-format": "^26.6.2" + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" }, "dependencies": { "ansi-styles": { @@ -29210,9 +29999,9 @@ } }, "jest-get-type": { - "version": "26.3.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", - "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==" + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==" }, "jest-haste-map": { "version": "26.6.2", @@ -32669,6 +33458,11 @@ } } }, + "material-colors": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz", + "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==" + }, "md5": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", @@ -35619,6 +36413,37 @@ "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=" }, + "passport": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.5.2.tgz", + "integrity": "sha512-w9n/Ot5I7orGD4y+7V3EFJCQEznE5RxHamUxcqLT2QoJY0f2JdN8GyHonYFvN0Vz+L6lUJfVhrk2aZz2LbuREw==", + "requires": { + "passport-strategy": "1.x.x", + "pause": "0.0.1" + } + }, + "passport-cookie": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/passport-cookie/-/passport-cookie-1.0.9.tgz", + "integrity": "sha512-8a6foX2bbGoJzup0RAiNcC2tTqzYS46RQEK3Z4u8p86wesPUjgDaji3C7+5j4TGyCq4ZoOV+3YLw1Hy6cV6kyw==", + "requires": { + "passport-strategy": "^1.0.0" + } + }, + "passport-jwt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.0.tgz", + "integrity": "sha512-BwC0n2GP/1hMVjR4QpnvqA61TxenUMlmfNjYNgK0ZAs0HK4SOQkHcSv4L328blNTLtHq7DbmvyNJiH+bn6C5Mg==", + "requires": { + "jsonwebtoken": "^8.2.0", + "passport-strategy": "^1.0.0" + } + }, + "passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" + }, "password-prompt": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/password-prompt/-/password-prompt-1.1.2.tgz", @@ -35709,6 +36534,11 @@ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" }, + "pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=" + }, "pbkdf2": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz", @@ -36731,36 +37561,19 @@ } }, "pretty-format": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", - "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "requires": { - "@jest/types": "^26.6.2", - "ansi-regex": "^5.0.0", - "ansi-styles": "^4.0.0", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", "react-is": "^17.0.1" }, "dependencies": { "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" }, "react-is": { "version": "17.0.2", @@ -38496,6 +39309,11 @@ "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=" }, + "resolve.exports": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.0.tgz", + "integrity": "sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==" + }, "responselike": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", @@ -40297,6 +41115,66 @@ } } }, + "superagent": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-7.1.1.tgz", + "integrity": "sha512-CQ2weSS6M+doIwwYFoMatklhRbx6sVNdB99OEJ5czcP3cng76Ljqus694knFWgOj3RkrtxZqIgpe6vhe0J7QWQ==", + "requires": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.3", + "debug": "^4.3.3", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.0.1", + "methods": "^1.1.2", + "mime": "^2.5.0", + "qs": "^6.10.1", + "readable-stream": "^3.6.0", + "semver": "^7.3.5" + }, + "dependencies": { + "formidable": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz", + "integrity": "sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==", + "requires": { + "dezalgo": "1.0.3", + "hexoid": "1.0.0", + "once": "1.4.0", + "qs": "6.9.3" + }, + "dependencies": { + "qs": { + "version": "6.9.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz", + "integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==" + } + } + }, + "mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==" + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "supertest": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.2.2.tgz", + "integrity": "sha512-wCw9WhAtKJsBvh07RaS+/By91NNE0Wh0DN19/hWPlBOU8tAfOtbZoVSV4xXeoKoxgPx0rx2y+y+8660XtE7jzg==", + "requires": { + "methods": "^1.1.2", + "superagent": "^7.1.0" + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -40903,6 +41781,11 @@ "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=" }, + "tinycolor2": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.2.tgz", + "integrity": "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==" + }, "title-case": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", @@ -41148,26 +42031,92 @@ "integrity": "sha512-e4g0EJtAjk64xgnFPD6kTBUtpnMVzDrMb12N1YZV0VvSlhnVT3SGxiYTLdGy8Q5cYHOIC/FAHmZ10eGrAguicQ==" }, "ts-jest": { - "version": "26.5.6", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-26.5.6.tgz", - "integrity": "sha512-rua+rCP8DxpA8b4DQD/6X2HQS8Zy/xzViVYfEs2OQu68tkCuKLV0Md8pmX55+W24uRIyAsf/BajRfxOs+R2MKA==", + "version": "27.1.3", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-27.1.3.tgz", + "integrity": "sha512-6Nlura7s6uM9BVUAoqLH7JHyMXjz8gluryjpPXxr3IxZdAXnU6FhjvVLHFtfd1vsE1p8zD1OJfskkc0jhTSnkA==", "requires": { "bs-logger": "0.x", - "buffer-from": "1.x", "fast-json-stable-stringify": "2.x", - "jest-util": "^26.1.0", + "jest-util": "^27.0.0", "json5": "2.x", - "lodash": "4.x", + "lodash.memoize": "4.x", "make-error": "1.x", - "mkdirp": "1.x", "semver": "7.x", "yargs-parser": "20.x" }, "dependencies": { - "mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "ci-info": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz", + "integrity": "sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw==" + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "requires": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + } }, "semver": { "version": "7.3.5", @@ -41177,6 +42126,14 @@ "lru-cache": "^6.0.0" } }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + }, "yargs-parser": { "version": "20.2.9", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", @@ -42216,9 +43173,9 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==" }, "v8-to-istanbul": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-7.1.2.tgz", - "integrity": "sha512-TxNb7YEUwkLXCQYeudi6lgQ/SZrzNO4kMdlqVxaZPUIUjCv6iSSypUQX70kNBSERpQ8fk48+d61FXk+tgqcWow==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", + "integrity": "sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==", "requires": { "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^1.6.0", @@ -42478,6 +43435,17 @@ "webpack-bundle-analyzer": "^3.6.0" } }, + "vue-color": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/vue-color/-/vue-color-2.8.1.tgz", + "integrity": "sha512-BoLCEHisXi2QgwlhZBg9UepvzZZmi4176vbr+31Shen5WWZwSLVgdScEPcB+yrAtuHAz42309C0A4+WiL9lNBw==", + "requires": { + "clamp": "^1.0.1", + "lodash.throttle": "^4.0.0", + "material-colors": "^1.0.0", + "tinycolor2": "^1.1.2" + } + }, "vue-docgen-api": { "version": "4.44.18", "resolved": "https://registry.npmjs.org/vue-docgen-api/-/vue-docgen-api-4.44.18.tgz", @@ -42659,6 +43627,16 @@ "resolved": "https://registry.npmjs.org/vue-typed-mixins/-/vue-typed-mixins-0.2.0.tgz", "integrity": "sha512-0OxuinandPWv3nm5k/reYkuKtX3jjPZ40Sy9roJz0ih8PUzmI7zSRiXFEJ62LsyRegw9Tqy+qMkajk7ipKP8Vg==" }, + "vue2-boring-avatars": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/vue2-boring-avatars/-/vue2-boring-avatars-0.3.4.tgz", + "integrity": "sha512-N3FYX9Z6rZdTeP3BOBz2LMxlWo9WRmPF6SOsYzz+tEuUH0QjX8UD7c1X95J8pZ7cFvbh9QflVujYQRqRiiwoAg==", + "requires": { + "core-js": "^3.6.5", + "vue": "^2.6.11", + "vue-color": "^2.8.1" + } + }, "vue2-touch-events": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/vue2-touch-events/-/vue2-touch-events-3.2.2.tgz", diff --git a/packages/cli/commands/Interfaces.d.ts b/packages/cli/commands/Interfaces.d.ts index c47f1680e5..41721bcc86 100644 --- a/packages/cli/commands/Interfaces.d.ts +++ b/packages/cli/commands/Interfaces.d.ts @@ -53,3 +53,14 @@ declare module 'json-diff' { } export function diff(obj1: unknown, obj2: unknown, diffOptions: IDiffOptions): string; } + +type SmtpConfig = { + host: string; + port: number; + secure: boolean; + auth: { + user: string; + pass: string; + }; + sender: string; +}; diff --git a/packages/cli/commands/execute.ts b/packages/cli/commands/execute.ts index 07677743e4..2d647cb233 100644 --- a/packages/cli/commands/execute.ts +++ b/packages/cli/commands/execute.ts @@ -28,6 +28,7 @@ import { import { getLogger } from '../src/Logger'; import config = require('../config'); +import { getInstanceOwner } from '../src/UserManagement/UserManagementHelper'; export class Execute extends Command { static description = '\nExecutes a given workflow'; @@ -169,11 +170,13 @@ export class Execute extends Command { } try { + const user = await getInstanceOwner(); const runData: IWorkflowExecutionDataProcess = { executionMode: 'cli', startNodes: [startNode.name], // eslint-disable-next-line @typescript-eslint/no-non-null-assertion workflowData: workflowData!, + userId: user.id, }; const workflowRunner = new WorkflowRunner(); diff --git a/packages/cli/commands/executeBatch.ts b/packages/cli/commands/executeBatch.ts index 3add81ac92..a1d5992b38 100644 --- a/packages/cli/commands/executeBatch.ts +++ b/packages/cli/commands/executeBatch.ts @@ -37,6 +37,8 @@ import { WorkflowRunner, } from '../src'; import config = require('../config'); +import { User } from '../src/databases/entities/User'; +import { getInstanceOwner } from '../src/UserManagement/UserManagementHelper'; export class ExecuteBatch extends Command { static description = '\nExecutes multiple workflows once'; @@ -57,6 +59,8 @@ export class ExecuteBatch extends Command { static executionTimeout = 3 * 60 * 1000; + static instanceOwner: User; + static examples = [ `$ n8n executeBatch`, `$ n8n executeBatch --concurrency=10 --skipList=/data/skipList.txt`, @@ -279,6 +283,8 @@ export class ExecuteBatch extends Command { // Wait till the database is ready await startDbInitPromise; + ExecuteBatch.instanceOwner = await getInstanceOwner(); + let allWorkflows; const query = Db.collections.Workflow!.createQueryBuilder('workflows'); @@ -666,6 +672,7 @@ export class ExecuteBatch extends Command { executionMode: 'cli', startNodes: [startNode!.name], workflowData, + userId: ExecuteBatch.instanceOwner.id, }; const workflowRunner = new WorkflowRunner(); diff --git a/packages/cli/commands/import/credentials.ts b/packages/cli/commands/import/credentials.ts index 01ce0e5a45..a4d799c604 100644 --- a/packages/cli/commands/import/credentials.ts +++ b/packages/cli/commands/import/credentials.ts @@ -1,3 +1,9 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable no-await-in-loop */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable no-console */ import { Command, flags } from '@oclif/command'; @@ -9,15 +15,25 @@ import { LoggerProxy } from 'n8n-workflow'; import * as fs from 'fs'; import * as glob from 'fast-glob'; import * as path from 'path'; +import { EntityManager, getConnection } from 'typeorm'; import { getLogger } from '../../src/Logger'; import { Db } from '../../src'; +import { User } from '../../src/databases/entities/User'; +import { SharedCredentials } from '../../src/databases/entities/SharedCredentials'; +import { Role } from '../../src/databases/entities/Role'; +import { CredentialsEntity } from '../../src/databases/entities/CredentialsEntity'; + +const FIX_INSTRUCTION = + 'Please fix the database by running ./packages/cli/bin/n8n user-management:reset'; export class ImportCredentialsCommand extends Command { static description = 'Import credentials'; static examples = [ - `$ n8n import:credentials --input=file.json`, - `$ n8n import:credentials --separate --input=backups/latest/`, + '$ n8n import:credentials --input=file.json', + '$ n8n import:credentials --separate --input=backups/latest/', + '$ n8n import:credentials --input=file.json --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', + '$ n8n import:credentials --separate --input=backups/latest/ --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', ]; static flags = { @@ -29,87 +45,161 @@ export class ImportCredentialsCommand extends Command { separate: flags.boolean({ description: 'Imports *.json files from directory provided by --input', }), + userId: flags.string({ + description: 'The ID of the user to assign the imported credentials to', + }), }; - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - async run() { + ownerCredentialRole: Role; + + transactionManager: EntityManager; + + async run(): Promise { const logger = getLogger(); LoggerProxy.init(logger); - // eslint-disable-next-line @typescript-eslint/no-shadow const { flags } = this.parse(ImportCredentialsCommand); if (!flags.input) { - console.info(`An input file or directory with --input must be provided`); + console.info('An input file or directory with --input must be provided'); return; } if (flags.separate) { if (fs.existsSync(flags.input)) { if (!fs.lstatSync(flags.input).isDirectory()) { - console.info(`The paramenter --input must be a directory`); + console.info('The argument to --input must be a directory'); return; } } } + let totalImported = 0; + try { await Db.init(); + await this.initOwnerCredentialRole(); + const user = flags.userId ? await this.getAssignee(flags.userId) : await this.getOwner(); + // Make sure the settings exist await UserSettings.prepareUserSettings(); - let i; const encryptionKey = await UserSettings.getEncryptionKey(); if (encryptionKey === undefined) { - throw new Error('No encryption key got found to encrypt the credentials!'); + throw new Error('No encryption key found to encrypt the credentials!'); } if (flags.separate) { const files = await glob( `${flags.input.endsWith(path.sep) ? flags.input : flags.input + path.sep}*.json`, ); - for (i = 0; i < files.length; i++) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const credential = JSON.parse(fs.readFileSync(files[i], { encoding: 'utf8' })); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + totalImported = files.length; + + await getConnection().transaction(async (transactionManager) => { + this.transactionManager = transactionManager; + for (const file of files) { + const credential = JSON.parse(fs.readFileSync(file, { encoding: 'utf8' })); + + if (typeof credential.data === 'object') { + // plain data / decrypted input. Should be encrypted first. + Credentials.prototype.setData.call(credential, credential.data, encryptionKey); + } + + await this.storeCredential(credential, user); + } + }); + + this.reportSuccess(totalImported); + process.exit(); + } + + const credentials = JSON.parse(fs.readFileSync(flags.input, { encoding: 'utf8' })); + + totalImported = credentials.length; + + if (!Array.isArray(credentials)) { + throw new Error( + 'File does not seem to contain credentials. Make sure the credentials are contained in an array.', + ); + } + + await getConnection().transaction(async (transactionManager) => { + this.transactionManager = transactionManager; + for (const credential of credentials) { if (typeof credential.data === 'object') { // plain data / decrypted input. Should be encrypted first. - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access Credentials.prototype.setData.call(credential, credential.data, encryptionKey); } - - // eslint-disable-next-line no-await-in-loop, @typescript-eslint/no-non-null-assertion - await Db.collections.Credentials!.save(credential); + await this.storeCredential(credential, user); } - } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const fileContents = JSON.parse(fs.readFileSync(flags.input, { encoding: 'utf8' })); + }); - if (!Array.isArray(fileContents)) { - throw new Error(`File does not seem to contain credentials.`); - } - - for (i = 0; i < fileContents.length; i++) { - if (typeof fileContents[i].data === 'object') { - // plain data / decrypted input. Should be encrypted first. - Credentials.prototype.setData.call( - fileContents[i], - fileContents[i].data, - encryptionKey, - ); - } - // eslint-disable-next-line no-await-in-loop, @typescript-eslint/no-non-null-assertion - await Db.collections.Credentials!.save(fileContents[i]); - } - } - console.info(`Successfully imported ${i} ${i === 1 ? 'credential.' : 'credentials.'}`); - process.exit(0); + this.reportSuccess(totalImported); + process.exit(); } catch (error) { - console.error('An error occurred while exporting credentials. See log messages for details.'); - logger.error(error.message); + console.error('An error occurred while importing credentials. See log messages for details.'); + if (error instanceof Error) logger.error(error.message); this.exit(1); } } + + private reportSuccess(total: number) { + console.info(`Successfully imported ${total} ${total === 1 ? 'workflow.' : 'workflows.'}`); + } + + private async initOwnerCredentialRole() { + const ownerCredentialRole = await Db.collections.Role!.findOne({ + where: { name: 'owner', scope: 'credential' }, + }); + + if (!ownerCredentialRole) { + throw new Error(`Failed to find owner credential role. ${FIX_INSTRUCTION}`); + } + + this.ownerCredentialRole = ownerCredentialRole; + } + + private async storeCredential(credential: object, user: User) { + const newCredential = new CredentialsEntity(); + + Object.assign(newCredential, credential); + + const savedCredential = await this.transactionManager.save(newCredential); + + const newSharedCredential = new SharedCredentials(); + + Object.assign(newSharedCredential, { + credentials: savedCredential, + user, + role: this.ownerCredentialRole, + }); + + await this.transactionManager.save(newSharedCredential); + } + + private async getOwner() { + const ownerGlobalRole = await Db.collections.Role!.findOne({ + where: { name: 'owner', scope: 'global' }, + }); + + const owner = await Db.collections.User!.findOne({ globalRole: ownerGlobalRole }); + + if (!owner) { + throw new Error(`Failed to find owner. ${FIX_INSTRUCTION}`); + } + + return owner; + } + + private async getAssignee(userId: string) { + const user = await Db.collections.User!.findOne(userId); + + if (!user) { + throw new Error(`Failed to find user with ID ${userId}`); + } + + return user; + } } diff --git a/packages/cli/commands/import/workflow.ts b/packages/cli/commands/import/workflow.ts index 6ca37d0000..a58f073167 100644 --- a/packages/cli/commands/import/workflow.ts +++ b/packages/cli/commands/import/workflow.ts @@ -1,3 +1,11 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable @typescript-eslint/restrict-template-expressions */ +/* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable @typescript-eslint/no-loop-func */ +/* eslint-disable no-await-in-loop */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable no-console */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { Command, flags } from '@oclif/command'; @@ -7,15 +15,25 @@ import { INode, INodeCredentialsDetails, LoggerProxy } from 'n8n-workflow'; import * as fs from 'fs'; import * as glob from 'fast-glob'; import { UserSettings } from 'n8n-core'; +import { EntityManager, getConnection } from 'typeorm'; import { getLogger } from '../../src/Logger'; import { Db, ICredentialsDb } from '../../src'; +import { SharedWorkflow } from '../../src/databases/entities/SharedWorkflow'; +import { WorkflowEntity } from '../../src/databases/entities/WorkflowEntity'; +import { Role } from '../../src/databases/entities/Role'; +import { User } from '../../src/databases/entities/User'; + +const FIX_INSTRUCTION = + 'Please fix the database by running ./packages/cli/bin/n8n user-management:reset'; export class ImportWorkflowsCommand extends Command { static description = 'Import workflows'; static examples = [ - `$ n8n import:workflow --input=file.json`, - `$ n8n import:workflow --separate --input=backups/latest/`, + '$ n8n import:workflow --input=file.json', + '$ n8n import:workflow --separate --input=backups/latest/', + '$ n8n import:workflow --input=file.json --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', + '$ n8n import:workflow --separate --input=backups/latest/ --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', ]; static flags = { @@ -27,12 +45,174 @@ export class ImportWorkflowsCommand extends Command { separate: flags.boolean({ description: 'Imports *.json files from directory provided by --input', }), + userId: flags.string({ + description: 'The ID of the user to assign the imported workflows to', + }), }; + ownerWorkflowRole: Role; + + transactionManager: EntityManager; + + async run(): Promise { + const logger = getLogger(); + LoggerProxy.init(logger); + + const { flags } = this.parse(ImportWorkflowsCommand); + + if (!flags.input) { + console.info('An input file or directory with --input must be provided'); + return; + } + + if (flags.separate) { + if (fs.existsSync(flags.input)) { + if (!fs.lstatSync(flags.input).isDirectory()) { + console.info('The argument to --input must be a directory'); + return; + } + } + } + + try { + await Db.init(); + + await this.initOwnerWorkflowRole(); + const user = flags.userId ? await this.getAssignee(flags.userId) : await this.getOwner(); + + // Make sure the settings exist + await UserSettings.prepareUserSettings(); + const credentials = (await Db.collections.Credentials?.find()) ?? []; + + let totalImported = 0; + + if (flags.separate) { + let { input: inputPath } = flags; + + if (process.platform === 'win32') { + inputPath = inputPath.replace(/\\/g, '/'); + } + + inputPath = inputPath.replace(/\/$/g, ''); + + const files = await glob(`${inputPath}/*.json`); + + totalImported = files.length; + + await getConnection().transaction(async (transactionManager) => { + this.transactionManager = transactionManager; + + for (const file of files) { + const workflow = JSON.parse(fs.readFileSync(file, { encoding: 'utf8' })); + + if (credentials.length > 0) { + workflow.nodes.forEach((node: INode) => { + this.transformCredentials(node, credentials); + }); + } + + await this.storeWorkflow(workflow, user); + } + }); + + this.reportSuccess(totalImported); + process.exit(); + } + + const workflows = JSON.parse(fs.readFileSync(flags.input, { encoding: 'utf8' })); + + totalImported = workflows.length; + + if (!Array.isArray(workflows)) { + throw new Error( + 'File does not seem to contain workflows. Make sure the workflows are contained in an array.', + ); + } + + await getConnection().transaction(async (transactionManager) => { + this.transactionManager = transactionManager; + + for (const workflow of workflows) { + if (credentials.length > 0) { + workflow.nodes.forEach((node: INode) => { + this.transformCredentials(node, credentials); + }); + } + + await this.storeWorkflow(workflow, user); + } + }); + + this.reportSuccess(totalImported); + process.exit(); + } catch (error) { + console.error('An error occurred while importing workflows. See log messages for details.'); + if (error instanceof Error) logger.error(error.message); + this.exit(1); + } + } + + private reportSuccess(total: number) { + console.info(`Successfully imported ${total} ${total === 1 ? 'workflow.' : 'workflows.'}`); + } + + private async initOwnerWorkflowRole() { + const ownerWorkflowRole = await Db.collections.Role!.findOne({ + where: { name: 'owner', scope: 'workflow' }, + }); + + if (!ownerWorkflowRole) { + throw new Error(`Failed to find owner workflow role. ${FIX_INSTRUCTION}`); + } + + this.ownerWorkflowRole = ownerWorkflowRole; + } + + private async storeWorkflow(workflow: object, user: User) { + const newWorkflow = new WorkflowEntity(); + + Object.assign(newWorkflow, workflow); + + const savedWorkflow = await this.transactionManager.save(newWorkflow); + + const newSharedWorkflow = new SharedWorkflow(); + + Object.assign(newSharedWorkflow, { + workflow: savedWorkflow, + user, + role: this.ownerWorkflowRole, + }); + + await this.transactionManager.save(newSharedWorkflow); + } + + private async getOwner() { + const ownerGlobalRole = await Db.collections.Role!.findOne({ + where: { name: 'owner', scope: 'global' }, + }); + + const owner = await Db.collections.User!.findOne({ globalRole: ownerGlobalRole }); + + if (!owner) { + throw new Error(`Failed to find owner. ${FIX_INSTRUCTION}`); + } + + return owner; + } + + private async getAssignee(userId: string) { + const user = await Db.collections.User!.findOne(userId); + + if (!user) { + throw new Error(`Failed to find user with ID ${userId}`); + } + + return user; + } + private transformCredentials(node: INode, credentialsEntities: ICredentialsDb[]) { if (node.credentials) { const allNodeCredentials = Object.entries(node.credentials); - // eslint-disable-next-line no-restricted-syntax for (const [type, name] of allNodeCredentials) { if (typeof name === 'string') { const nodeCredentials: INodeCredentialsDetails = { @@ -54,80 +234,4 @@ export class ImportWorkflowsCommand extends Command { } } } - - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - async run() { - const logger = getLogger(); - LoggerProxy.init(logger); - - // eslint-disable-next-line @typescript-eslint/no-shadow - const { flags } = this.parse(ImportWorkflowsCommand); - - if (!flags.input) { - console.info(`An input file or directory with --input must be provided`); - return; - } - - if (flags.separate) { - if (fs.existsSync(flags.input)) { - if (!fs.lstatSync(flags.input).isDirectory()) { - console.info(`The paramenter --input must be a directory`); - return; - } - } - } - - try { - await Db.init(); - - // Make sure the settings exist - await UserSettings.prepareUserSettings(); - const credentialsEntities = (await Db.collections.Credentials?.find()) ?? []; - let i; - if (flags.separate) { - let inputPath = flags.input; - if (process.platform === 'win32') { - inputPath = inputPath.replace(/\\/g, '/'); - } - inputPath = inputPath.replace(/\/$/g, ''); - const files = await glob(`${inputPath}/*.json`); - for (i = 0; i < files.length; i++) { - const workflow = JSON.parse(fs.readFileSync(files[i], { encoding: 'utf8' })); - if (credentialsEntities.length > 0) { - // eslint-disable-next-line - workflow.nodes.forEach((node: INode) => { - this.transformCredentials(node, credentialsEntities); - }); - } - // eslint-disable-next-line no-await-in-loop, @typescript-eslint/no-non-null-assertion - await Db.collections.Workflow!.save(workflow); - } - } else { - const fileContents = JSON.parse(fs.readFileSync(flags.input, { encoding: 'utf8' })); - - if (!Array.isArray(fileContents)) { - throw new Error(`File does not seem to contain workflows.`); - } - - for (i = 0; i < fileContents.length; i++) { - if (credentialsEntities.length > 0) { - // eslint-disable-next-line - fileContents[i].nodes.forEach((node: INode) => { - this.transformCredentials(node, credentialsEntities); - }); - } - // eslint-disable-next-line no-await-in-loop, @typescript-eslint/no-non-null-assertion - await Db.collections.Workflow!.save(fileContents[i]); - } - } - - console.info(`Successfully imported ${i} ${i === 1 ? 'workflow.' : 'workflows.'}`); - process.exit(0); - } catch (error) { - console.error('An error occurred while exporting workflows. See log messages for details.'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - logger.error(error.message); - this.exit(1); - } - } } diff --git a/packages/cli/commands/start.ts b/packages/cli/commands/start.ts index c85c9c0c3f..66dcd81eb1 100644 --- a/packages/cli/commands/start.ts +++ b/packages/cli/commands/start.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/await-thenable */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ @@ -12,6 +13,7 @@ import { Command, flags } from '@oclif/command'; import * as Redis from 'ioredis'; import { IDataObject, LoggerProxy } from 'n8n-workflow'; +import { createHash } from 'crypto'; import * as config from '../config'; import { ActiveExecutions, @@ -31,6 +33,7 @@ import { } from '../src'; import { getLogger } from '../src/Logger'; +import { RESPONSE_ERROR_MESSAGES } from '../src/constants'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires const open = require('open'); @@ -166,6 +169,26 @@ export class Start extends Command { // Make sure the settings exist const userSettings = await UserSettings.prepareUserSettings(); + if (!config.get('userManagement.jwtSecret')) { + // If we don't have a JWT secret set, generate + // one based and save to config. + const encryptionKey = await UserSettings.getEncryptionKey(); + if (!encryptionKey) { + throw new Error('Fatal error setting up user management: no encryption key set.'); + } + + // For a key off every other letter from encryption key + // CAREFUL: do not change this or it breaks all existing tokens. + let baseKey = ''; + for (let i = 0; i < encryptionKey.length; i += 2) { + baseKey += encryptionKey[i]; + } + config.set( + 'userManagement.jwtSecret', + createHash('sha256').update(baseKey).digest('hex'), + ); + } + // Load all node and credential types const loadNodesAndCredentials = LoadNodesAndCredentials(); await loadNodesAndCredentials.init(); @@ -187,6 +210,18 @@ export class Start extends Command { // Wait till the database is ready await startDbInitPromise; + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { + throw new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY); + } + + // Load settings from database and set them to config. + const databaseSettings = await Db.collections.Settings!.find({ loadOnStartup: true }); + databaseSettings.forEach((setting) => { + config.set(setting.key, JSON.parse(setting.value)); + }); + if (config.get('executions.mode') === 'queue') { const redisHost = config.get('queue.bull.redis.host'); const redisPassword = config.get('queue.bull.redis.password'); @@ -319,6 +354,12 @@ export class Start extends Command { const editorUrl = GenericHelpers.getBaseUrl(); this.log(`\nEditor is now accessible via:\n${editorUrl}`); + const saveManualExecutions = config.get('executions.saveDataManualExecutions') as boolean; + + if (saveManualExecutions) { + this.log('\nManual executions will be visible only for the owner'); + } + // Allow to open n8n editor by pressing "o" if (Boolean(process.stdout.isTTY) && process.stdin.setRawMode) { process.stdin.setRawMode(true); diff --git a/packages/cli/commands/user-management/reset.ts b/packages/cli/commands/user-management/reset.ts new file mode 100644 index 0000000000..404e5a8df1 --- /dev/null +++ b/packages/cli/commands/user-management/reset.ts @@ -0,0 +1,86 @@ +/* eslint-disable no-console */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +import Command from '@oclif/command'; +import { Not } from 'typeorm'; +import { LoggerProxy } from 'n8n-workflow'; +import { Db } from '../../src'; +import { User } from '../../src/databases/entities/User'; +import { getLogger } from '../../src/Logger'; + +export class Reset extends Command { + static description = '\nResets the database to the default user state'; + + private defaultUserProps = { + firstName: null, + lastName: null, + email: null, + password: null, + resetPasswordToken: null, + }; + + async run(): Promise { + const logger = getLogger(); + LoggerProxy.init(logger); + await Db.init(); + + try { + const owner = await this.getInstanceOwner(); + + const ownerWorkflowRole = await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'workflow', + }); + + const ownerCredentialRole = await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'credential', + }); + + await Db.collections.SharedWorkflow!.update( + { user: { id: Not(owner.id) }, role: ownerWorkflowRole }, + { user: owner }, + ); + + await Db.collections.SharedCredentials!.update( + { user: { id: Not(owner.id) }, role: ownerCredentialRole }, + { user: owner }, + ); + await Db.collections.User!.delete({ id: Not(owner.id) }); + await Db.collections.User!.save(Object.assign(owner, this.defaultUserProps)); + + await Db.collections.Settings!.update( + { key: 'userManagement.isInstanceOwnerSetUp' }, + { value: 'false' }, + ); + await Db.collections.Settings!.update( + { key: 'userManagement.skipInstanceOwnerSetup' }, + { value: 'false' }, + ); + } catch (error) { + console.error('Error resetting database. See log messages for details.'); + if (error instanceof Error) logger.error(error.message); + this.exit(1); + } + + console.info('Successfully reset the database to default user state.'); + this.exit(); + } + + private async getInstanceOwner(): Promise { + const globalRole = await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'global', + }); + + const owner = await Db.collections.User!.findOne({ globalRole }); + + if (owner) return owner; + + const user = new User(); + + await Db.collections.User!.save(Object.assign(user, { ...this.defaultUserProps, globalRole })); + + return Db.collections.User!.findOneOrFail({ globalRole }); + } +} diff --git a/packages/cli/commands/worker.ts b/packages/cli/commands/worker.ts index 812bf42802..1f3f3b9fe0 100644 --- a/packages/cli/commands/worker.ts +++ b/packages/cli/commands/worker.ts @@ -41,6 +41,10 @@ import { getLogger } from '../src/Logger'; import * as config from '../config'; import * as Queue from '../src/Queue'; +import { + checkPermissionsForExecution, + getWorkflowOwner, +} from '../src/UserManagement/UserManagementHelper'; export class Worker extends Command { static description = '\nStarts a n8n worker'; @@ -123,6 +127,8 @@ export class Worker extends Command { `Start job: ${job.id} (Workflow ID: ${currentExecutionDb.workflowData.id} | Execution: ${jobData.executionId})`, ); + const workflowOwner = await getWorkflowOwner(currentExecutionDb.workflowData.id!.toString()); + let { staticData } = currentExecutionDb.workflowData; if (jobData.loadStaticData) { const findOptions = { @@ -166,7 +172,10 @@ export class Worker extends Command { settings: currentExecutionDb.workflowData.settings, }); + await checkPermissionsForExecution(workflow, workflowOwner.id); + const additionalData = await WorkflowExecuteAdditionalData.getBase( + workflowOwner.id, undefined, executionTimeoutTimestamp, ); diff --git a/packages/cli/config/index.ts b/packages/cli/config/index.ts index 9ca8a913e3..1e5b25f933 100644 --- a/packages/cli/config/index.ts +++ b/packages/cli/config/index.ts @@ -351,6 +351,7 @@ const config = convict({ }, }, }, + generic: { // The timezone to use. Is important for nodes like "Cron" which start the // workflow automatically at a specified time. This setting can also be @@ -410,6 +411,12 @@ const config = convict({ env: 'N8N_SSL_CERT', doc: 'SSL Cert for HTTPS Protocol', }, + editorBaseUrl: { + format: String, + default: '', + env: 'N8N_EDITOR_BASE_URL', + doc: 'Public URL where the editor is accessible. Also used for emails sent from n8n.', + }, security: { excludeEndpoints: { @@ -573,6 +580,90 @@ const config = convict({ }, }, + workflowTagsDisabled: { + format: Boolean, + default: false, + env: 'N8N_WORKFLOW_TAGS_DISABLED', + doc: 'Disable worfklow tags.', + }, + + userManagement: { + disabled: { + doc: 'Disable user management and hide it completely.', + format: Boolean, + default: false, + env: 'N8N_USER_MANAGEMENT_DISABLED', + }, + jwtSecret: { + doc: 'Set a specific JWT secret (optional - n8n can generate one)', // Generated @ start.ts + format: String, + default: '', + env: 'N8N_USER_MANAGEMENT_JWT_SECRET', + }, + emails: { + mode: { + doc: 'How to send emails', + format: ['', 'smtp'], + default: 'smtp', + env: 'N8N_EMAIL_MODE', + }, + smtp: { + host: { + doc: 'SMTP server host', + format: String, // e.g. 'smtp.gmail.com' + default: '', + env: 'N8N_SMTP_HOST', + }, + port: { + doc: 'SMTP server port', + format: Number, + default: 465, + env: 'N8N_SMTP_PORT', + }, + secure: { + doc: 'Whether or not to use SSL for SMTP', + format: Boolean, + default: true, + env: 'N8N_SMTP_SSL', + }, + auth: { + user: { + doc: 'SMTP login username', + format: String, // e.g.'you@gmail.com' + default: '', + env: 'N8N_SMTP_USER', + }, + pass: { + doc: 'SMTP login password', + format: String, + default: '', + env: 'N8N_SMTP_PASS', + }, + }, + sender: { + doc: 'How to display sender name', + format: String, + default: '', + env: 'N8N_SMTP_SENDER', + }, + }, + templates: { + invite: { + doc: 'Overrides default HTML template for inviting new people (use full path)', + format: String, + default: '', + env: 'N8N_UM_EMAIL_TEMPLATES_INVITE', + }, + passwordReset: { + doc: 'Overrides default HTML template for resetting password (use full path)', + format: String, + default: '', + env: 'N8N_UM_EMAIL_TEMPLATES_PWRESET', + }, + }, + }, + }, + externalHookFiles: { doc: 'Files containing external hooks. Multiple files can be separated by colon (":")', format: String, @@ -636,8 +727,8 @@ const config = convict({ logs: { level: { - doc: 'Log output level. Options are error, warn, info, verbose and debug.', - format: String, + doc: 'Log output level', + format: ['error', 'warn', 'info', 'verbose', 'debug'], default: 'info', env: 'N8N_LOG_LEVEL', }, @@ -713,10 +804,10 @@ const config = convict({ doc: 'Available modes of binary data storage, as comma separated strings', }, mode: { - format: String, + format: ['default', 'filesystem'], default: 'default', env: 'N8N_DEFAULT_BINARY_DATA_MODE', - doc: 'Storage mode for binary data, default | filesystem', + doc: 'Storage mode for binary data', }, localStoragePath: { format: String, diff --git a/packages/cli/jest.config.js b/packages/cli/jest.config.js new file mode 100644 index 0000000000..267cc93266 --- /dev/null +++ b/packages/cli/jest.config.js @@ -0,0 +1,17 @@ +module.exports = { + verbose: true, + transform: { + '^.+\\.ts?$': 'ts-jest', + }, + testURL: 'http://localhost/', + testRegex: '(/__tests__/.*|(\\.|/)(test))\\.ts$', + testPathIgnorePatterns: ['/dist/', '/node_modules/'], + moduleFileExtensions: ['ts', 'js', 'json'], + globals: { + 'ts-jest': { + isolatedModules: true, + }, + }, + globalTeardown: '/test/teardown.ts', + setupFiles: ['/test/setup.ts'], +}; diff --git a/packages/cli/package.json b/packages/cli/package.json index 42eb3f4839..b853574ec4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,7 +19,7 @@ "bin": "n8n" }, "scripts": { - "build": "tsc", + "build": "tsc && cp -r ./src/UserManagement/email/templates ./dist/src/UserManagement/email", "dev": "concurrently -k -n \"TypeScript,Node\" -c \"yellow.bold,cyan.bold\" \"npm run watch\" \"nodemon\"", "format": "cd ../.. && node_modules/prettier/bin-prettier.js packages/cli/**/**.ts --write", "lint": "cd ../.. && node_modules/eslint/bin/eslint.js packages/cli", @@ -29,7 +29,10 @@ "start": "run-script-os", "start:default": "cd bin && ./n8n", "start:windows": "cd bin && n8n", - "test": "jest", + "test": "npm run test:sqlite", + "test:sqlite": "export DB_TYPE=sqlite && jest", + "test:postgres": "export DB_TYPE=postgresdb && jest", + "test:mysql": "export DB_TYPE=mysqldb && jest", "watch": "tsc --watch", "typeorm": "ts-node ../../node_modules/typeorm/cli.js" }, @@ -61,23 +64,29 @@ "@types/compression": "1.0.1", "@types/connect-history-api-fallback": "^1.3.1", "@types/convict": "^4.2.1", + "@types/cookie-parser": "^1.4.2", "@types/dotenv": "^8.2.0", "@types/express": "^4.17.6", - "@types/jest": "^26.0.13", + "@types/jest": "^27.4.0", "@types/localtunnel": "^1.9.0", "@types/lodash.get": "^4.4.6", "@types/lodash.merge": "^4.6.6", "@types/node": "14.17.27", "@types/open": "^6.1.0", "@types/parseurl": "^1.3.1", + "@types/passport-jwt": "^3.0.6", "@types/request-promise-native": "~1.0.15", + "@types/superagent": "4.1.13", + "@types/supertest": "^2.0.11", + "@types/uuid": "^8.3.0", "@types/validator": "^13.7.0", "axios": "^0.21.1", "concurrently": "^5.1.0", - "jest": "^26.4.2", + "jest": "^27.4.7", "nodemon": "^2.0.2", "run-script-os": "^1.0.7", - "ts-jest": "^26.3.0", + "supertest": "^6.2.2", + "ts-jest": "^27.1.3", "ts-node": "^8.9.1", "tslint": "^6.1.2", "typescript": "~4.3.5" @@ -94,11 +103,14 @@ "body-parser-xml": "^2.0.3", "bull": "^3.19.0", "callsites": "^3.1.0", + "change-case": "^4.1.1", "class-validator": "^0.13.1", "client-oauth2": "^4.2.5", "compression": "^1.7.4", "connect-history-api-fallback": "^1.6.0", "convict": "^6.0.1", + "cookie-parser": "^1.4.6", + "crypto-js": "^4.1.1", "csrf": "^3.1.0", "dotenv": "^8.0.0", "express": "^4.16.4", @@ -117,33 +129,22 @@ "n8n-editor-ui": "~0.134.0", "n8n-nodes-base": "~0.165.0", "n8n-workflow": "~0.90.0", + "nodemailer": "^6.7.1", "oauth-1.0a": "^2.2.6", "open": "^7.0.0", "p-cancelable": "^2.0.0", + "passport": "^0.5.0", + "passport-cookie": "^1.0.9", + "passport-jwt": "^4.0.0", "pg": "^8.3.0", "prom-client": "^13.1.0", "request-promise-native": "^1.0.7", - "sqlite3": "^5.0.1", + "sqlite3": "^5.0.2", "sse-channel": "^3.1.1", "tslib": "1.14.1", "typeorm": "0.2.30", + "uuid": "^8.3.0", + "validator": "13.7.0", "winston": "^3.3.3" - }, - "jest": { - "transform": { - "^.+\\.tsx?$": "ts-jest" - }, - "testURL": "http://localhost/", - "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", - "testPathIgnorePatterns": [ - "/dist/", - "/node_modules/" - ], - "moduleFileExtensions": [ - "ts", - "tsx", - "js", - "json" - ] } } diff --git a/packages/cli/packages/cli/database.sqlite b/packages/cli/packages/cli/database.sqlite deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index 7f2fdec9da..bc24be4019 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable prefer-spread */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable no-param-reassign */ @@ -48,6 +49,9 @@ import { ExternalHooks, } from '.'; import config = require('../config'); +import { User } from './databases/entities/User'; +import { whereClause } from './WorkflowHelpers'; +import { WorkflowEntity } from './databases/entities/WorkflowEntity'; const WEBHOOK_PROD_UNREGISTERED_HINT = `The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)`; @@ -66,7 +70,8 @@ export class ActiveWorkflowRunner { // Here I guess we can have a flag on the workflow table like hasTrigger // so intead of pulling all the active wehhooks just pull the actives that have a trigger const workflowsData: IWorkflowDb[] = (await Db.collections.Workflow!.find({ - active: true, + where: { active: true }, + relations: ['shared', 'shared.user', 'shared.user.globalRole'], })) as IWorkflowDb[]; if (!config.get('endpoints.skipWebhoooksDeregistrationOnShutdown')) { @@ -102,7 +107,7 @@ export class ActiveWorkflowRunner { }); console.log(` => Started`); } catch (error) { - console.log(` => ERROR: Workflow could not be activated:`); + console.log(` => ERROR: Workflow could not be activated`); // eslint-disable-next-line @typescript-eslint/restrict-template-expressions console.log(` ${error.message}`); Logger.error(`Unable to initialize workflow "${workflowData.name}" (startup)`, { @@ -251,7 +256,9 @@ export class ActiveWorkflowRunner { }); } - const workflowData = await Db.collections.Workflow!.findOne(webhook.workflowId); + const workflowData = await Db.collections.Workflow!.findOne(webhook.workflowId, { + relations: ['shared', 'shared.user', 'shared.user.globalRole'], + }); if (workflowData === undefined) { throw new ResponseHelper.ResponseError( `Could not find workflow with id "${webhook.workflowId}"`, @@ -272,7 +279,9 @@ export class ActiveWorkflowRunner { settings: workflowData.settings, }); - const additionalData = await WorkflowExecuteAdditionalData.getBase(); + const additionalData = await WorkflowExecuteAdditionalData.getBase( + workflowData.shared[0].user.id, + ); const webhookData = NodeHelpers.getNodeWebhooks( workflow, @@ -336,14 +345,30 @@ export class ActiveWorkflowRunner { * @returns {string[]} * @memberof ActiveWorkflowRunner */ - async getActiveWorkflows(): Promise { - const activeWorkflows = (await Db.collections.Workflow?.find({ - where: { active: true }, - select: ['id'], - })) as IWorkflowDb[]; - return activeWorkflows.filter( - (workflow) => this.activationErrors[workflow.id.toString()] === undefined, - ); + async getActiveWorkflows(user?: User): Promise { + let activeWorkflows: WorkflowEntity[] = []; + + if (!user || user.globalRole.name === 'owner') { + activeWorkflows = await Db.collections.Workflow!.find({ + select: ['id'], + where: { active: true }, + }); + } else { + const shared = await Db.collections.SharedWorkflow!.find({ + relations: ['workflow'], + where: whereClause({ + user, + entityType: 'workflow', + }), + }); + + activeWorkflows = shared.reduce((acc, cur) => { + if (cur.workflow.active) acc.push(cur.workflow); + return acc; + }, []); + } + + return activeWorkflows.filter((workflow) => this.activationErrors[workflow.id] === undefined); } /** @@ -354,8 +379,8 @@ export class ActiveWorkflowRunner { * @memberof ActiveWorkflowRunner */ async isActive(id: string): Promise { - const workflow = (await Db.collections.Workflow?.findOne({ id: Number(id) })) as IWorkflowDb; - return workflow?.active; + const workflow = await Db.collections.Workflow!.findOne(id); + return !!workflow?.active; } /** @@ -462,19 +487,14 @@ export class ActiveWorkflowRunner { ); } - let errorMessage = ''; - // if it's a workflow from the the insert // TODO check if there is standard error code for duplicate key violation that works // with all databases if (error.name === 'QueryFailedError') { - errorMessage = `The webhook path [${webhook.webhookPath}] and method [${webhook.method}] already exist.`; + error.message = `The URL path that the "${webhook.node}" node uses is already taken. Please change it to something else.`; } else if (error.detail) { // it's a error runnig the webhook methods (checkExists, create) - errorMessage = error.detail; - } else { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - errorMessage = error.message; + error.message = error.detail; } throw error; @@ -492,7 +512,9 @@ export class ActiveWorkflowRunner { * @memberof ActiveWorkflowRunner */ async removeWorkflowWebhooks(workflowId: string): Promise { - const workflowData = await Db.collections.Workflow!.findOne(workflowId); + const workflowData = await Db.collections.Workflow!.findOne(workflowId, { + relations: ['shared', 'shared.user', 'shared.user.globalRole'], + }); if (workflowData === undefined) { throw new Error(`Could not find workflow with id "${workflowId}"`); } @@ -511,7 +533,9 @@ export class ActiveWorkflowRunner { const mode = 'internal'; - const additionalData = await WorkflowExecuteAdditionalData.getBase(); + const additionalData = await WorkflowExecuteAdditionalData.getBase( + workflowData.shared[0].user.id, + ); const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, undefined, true); @@ -578,6 +602,7 @@ export class ActiveWorkflowRunner { // Start the workflow const runData: IWorkflowExecutionDataProcess = { + userId: additionalData.userId, executionMode: mode, executionData, workflowData, @@ -681,7 +706,9 @@ export class ActiveWorkflowRunner { let workflowInstance: Workflow; try { if (workflowData === undefined) { - workflowData = (await Db.collections.Workflow!.findOne(workflowId)) as IWorkflowDb; + workflowData = (await Db.collections.Workflow!.findOne(workflowId, { + relations: ['shared', 'shared.user', 'shared.user.globalRole'], + })) as IWorkflowDb; } if (!workflowData) { @@ -710,7 +737,9 @@ export class ActiveWorkflowRunner { } const mode = 'trigger'; - const additionalData = await WorkflowExecuteAdditionalData.getBase(); + const additionalData = await WorkflowExecuteAdditionalData.getBase( + (workflowData as WorkflowEntity).shared[0].user.id, + ); const getTriggerFunctions = this.getExecuteTriggerFunctions( workflowData, additionalData, diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index e7abf5b0ae..6806dd0294 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -35,6 +35,7 @@ import { Workflow, WorkflowExecuteMode, ITaskDataConnections, + LoggerProxy as Logger, } from 'n8n-workflow'; // eslint-disable-next-line import/no-cycle @@ -44,8 +45,11 @@ import { Db, ICredentialsDb, NodeTypes, + WhereClause, WorkflowExecuteAdditionalData, } from '.'; +// eslint-disable-next-line import/no-cycle +import { User } from './databases/entities/User'; const mockNodeTypes: INodeTypes = { nodeTypes: {} as INodeTypeData, @@ -209,32 +213,40 @@ export class CredentialsHelper extends ICredentialsHelper { /** * Returns the credentials instance * - * @param {INodeCredentialsDetails} nodeCredentials id and name to return instance of - * @param {string} type Type of the credentials to return instance of + * @param {INodeCredentialsDetails} nodeCredential id and name to return instance of + * @param {string} type Type of the credential to return instance of * @returns {Credentials} * @memberof CredentialsHelper */ async getCredentials( - nodeCredentials: INodeCredentialsDetails, + nodeCredential: INodeCredentialsDetails, type: string, + userId?: string, ): Promise { - if (!nodeCredentials.id) { - throw new Error(`Credentials "${nodeCredentials.name}" for type "${type}" don't have an ID.`); + if (!nodeCredential.id) { + throw new Error(`Credential "${nodeCredential.name}" of type "${type}" has no ID.`); } - const credentials = await Db.collections.Credentials?.findOne({ id: nodeCredentials.id, type }); + const credential = userId + ? await Db.collections + .SharedCredentials!.findOneOrFail({ + relations: ['credentials'], + where: { credentials: { id: nodeCredential.id, type }, user: { id: userId } }, + }) + .then((shared) => shared.credentials) + : await Db.collections.Credentials!.findOneOrFail({ id: nodeCredential.id, type }); - if (!credentials) { + if (!credential) { throw new Error( - `Credentials with ID "${nodeCredentials.id}" don't exist for type "${type}".`, + `Credential with ID "${nodeCredential.id}" does not exist for type "${type}".`, ); } return new Credentials( - { id: credentials.id.toString(), name: credentials.name }, - credentials.type, - credentials.nodesAccess, - credentials.data, + { id: credential.id.toString(), name: credential.name }, + credential.type, + credential.nodesAccess, + credential.data, ); } @@ -504,6 +516,7 @@ export class CredentialsHelper extends ICredentialsHelper { } async testCredentials( + user: User, credentialType: string, credentialsDecrypted: ICredentialsDecrypted, nodeToTestWith?: string, @@ -602,7 +615,7 @@ export class CredentialsHelper extends ICredentialsHelper { }, }; - const additionalData = await WorkflowExecuteAdditionalData.getBase(node.parameters); + const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id, node.parameters); const routingNode = new RoutingNode( workflow, @@ -656,7 +669,7 @@ export class CredentialsHelper extends ICredentialsHelper { }; } } - + Logger.debug('Credential test failed', error); return { status: 'Error', message: error.message.toString(), @@ -669,3 +682,47 @@ export class CredentialsHelper extends ICredentialsHelper { }; } } + +/** + * Build a `where` clause for a `find()` or `findOne()` operation + * in the `shared_workflow` or `shared_credentials` tables. + */ +export function whereClause({ + user, + entityType, + entityId = '', +}: { + user: User; + entityType: 'workflow' | 'credentials'; + entityId?: string; +}): WhereClause { + const where: WhereClause = entityId ? { [entityType]: { id: entityId } } : {}; + + if (user.globalRole.name !== 'owner') { + where.user = { id: user.id }; + } + + return where; +} + +/** + * Get a credential if it has been shared with a user. + */ +export async function getCredentialForUser( + credentialId: string, + user: User, +): Promise { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const sharedCredential = await Db.collections.SharedCredentials!.findOne({ + relations: ['credentials'], + where: whereClause({ + user, + entityType: 'credentials', + entityId: credentialId, + }), + }); + + if (!sharedCredential) return null; + + return sharedCredential.credentials as ICredentialsDb; +} diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index cf5f368494..db788c521b 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -1,9 +1,19 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/restrict-template-expressions */ /* eslint-disable no-case-declarations */ /* eslint-disable @typescript-eslint/naming-convention */ import { UserSettings } from 'n8n-core'; -import { ConnectionOptions, createConnection, getRepository, LoggerOptions } from 'typeorm'; +import { + Connection, + ConnectionOptions, + createConnection, + EntityManager, + EntityTarget, + getRepository, + LoggerOptions, + Repository, +} from 'typeorm'; import { TlsOptions } from 'tls'; import * as path from 'path'; // eslint-disable-next-line import/no-cycle @@ -24,9 +34,26 @@ export const collections: IDatabaseCollections = { Workflow: null, Webhook: null, Tag: null, + Role: null, + User: null, + SharedCredentials: null, + SharedWorkflow: null, + Settings: null, }; -export async function init(): Promise { +let connection: Connection; + +export async function transaction(fn: (entityManager: EntityManager) => Promise): Promise { + return connection.transaction(fn); +} + +export function linkRepository(entityClass: EntityTarget): Repository { + return getRepository(entityClass, connection.name); +} + +export async function init( + testConnectionOptions?: ConnectionOptions, +): Promise { const dbType = (await GenericHelpers.getConfigValue('database.type')) as DatabaseType; const n8nFolder = UserSettings.getUserN8nFolderPath(); @@ -34,74 +61,80 @@ export async function init(): Promise { const entityPrefix = config.get('database.tablePrefix'); - switch (dbType) { - case 'postgresdb': - const sslCa = (await GenericHelpers.getConfigValue('database.postgresdb.ssl.ca')) as string; - const sslCert = (await GenericHelpers.getConfigValue( - 'database.postgresdb.ssl.cert', - )) as string; - const sslKey = (await GenericHelpers.getConfigValue('database.postgresdb.ssl.key')) as string; - const sslRejectUnauthorized = (await GenericHelpers.getConfigValue( - 'database.postgresdb.ssl.rejectUnauthorized', - )) as boolean; + if (testConnectionOptions) { + connectionOptions = testConnectionOptions; + } else { + switch (dbType) { + case 'postgresdb': + const sslCa = (await GenericHelpers.getConfigValue('database.postgresdb.ssl.ca')) as string; + const sslCert = (await GenericHelpers.getConfigValue( + 'database.postgresdb.ssl.cert', + )) as string; + const sslKey = (await GenericHelpers.getConfigValue( + 'database.postgresdb.ssl.key', + )) as string; + const sslRejectUnauthorized = (await GenericHelpers.getConfigValue( + 'database.postgresdb.ssl.rejectUnauthorized', + )) as boolean; - let ssl: TlsOptions | undefined; - if (sslCa !== '' || sslCert !== '' || sslKey !== '' || !sslRejectUnauthorized) { - ssl = { - ca: sslCa || undefined, - cert: sslCert || undefined, - key: sslKey || undefined, - rejectUnauthorized: sslRejectUnauthorized, + let ssl: TlsOptions | undefined; + if (sslCa !== '' || sslCert !== '' || sslKey !== '' || !sslRejectUnauthorized) { + ssl = { + ca: sslCa || undefined, + cert: sslCert || undefined, + key: sslKey || undefined, + rejectUnauthorized: sslRejectUnauthorized, + }; + } + + connectionOptions = { + type: 'postgres', + entityPrefix, + database: (await GenericHelpers.getConfigValue('database.postgresdb.database')) as string, + host: (await GenericHelpers.getConfigValue('database.postgresdb.host')) as string, + password: (await GenericHelpers.getConfigValue('database.postgresdb.password')) as string, + port: (await GenericHelpers.getConfigValue('database.postgresdb.port')) as number, + username: (await GenericHelpers.getConfigValue('database.postgresdb.user')) as string, + schema: config.get('database.postgresdb.schema'), + migrations: postgresMigrations, + migrationsRun: true, + migrationsTableName: `${entityPrefix}migrations`, + ssl, }; - } - connectionOptions = { - type: 'postgres', - entityPrefix, - database: (await GenericHelpers.getConfigValue('database.postgresdb.database')) as string, - host: (await GenericHelpers.getConfigValue('database.postgresdb.host')) as string, - password: (await GenericHelpers.getConfigValue('database.postgresdb.password')) as string, - port: (await GenericHelpers.getConfigValue('database.postgresdb.port')) as number, - username: (await GenericHelpers.getConfigValue('database.postgresdb.user')) as string, - schema: config.get('database.postgresdb.schema'), - migrations: postgresMigrations, - migrationsRun: true, - migrationsTableName: `${entityPrefix}migrations`, - ssl, - }; + break; - break; + case 'mariadb': + case 'mysqldb': + connectionOptions = { + type: dbType === 'mysqldb' ? 'mysql' : 'mariadb', + database: (await GenericHelpers.getConfigValue('database.mysqldb.database')) as string, + entityPrefix, + host: (await GenericHelpers.getConfigValue('database.mysqldb.host')) as string, + password: (await GenericHelpers.getConfigValue('database.mysqldb.password')) as string, + port: (await GenericHelpers.getConfigValue('database.mysqldb.port')) as number, + username: (await GenericHelpers.getConfigValue('database.mysqldb.user')) as string, + migrations: mysqlMigrations, + migrationsRun: true, + migrationsTableName: `${entityPrefix}migrations`, + timezone: 'Z', // set UTC as default + }; + break; - case 'mariadb': - case 'mysqldb': - connectionOptions = { - type: dbType === 'mysqldb' ? 'mysql' : 'mariadb', - database: (await GenericHelpers.getConfigValue('database.mysqldb.database')) as string, - entityPrefix, - host: (await GenericHelpers.getConfigValue('database.mysqldb.host')) as string, - password: (await GenericHelpers.getConfigValue('database.mysqldb.password')) as string, - port: (await GenericHelpers.getConfigValue('database.mysqldb.port')) as number, - username: (await GenericHelpers.getConfigValue('database.mysqldb.user')) as string, - migrations: mysqlMigrations, - migrationsRun: true, - migrationsTableName: `${entityPrefix}migrations`, - timezone: 'Z', // set UTC as default - }; - break; + case 'sqlite': + connectionOptions = { + type: 'sqlite', + database: path.join(n8nFolder, 'database.sqlite'), + entityPrefix, + migrations: sqliteMigrations, + migrationsRun: false, // migrations for sqlite will be ran manually for now; see below + migrationsTableName: `${entityPrefix}migrations`, + }; + break; - case 'sqlite': - connectionOptions = { - type: 'sqlite', - database: path.join(n8nFolder, 'database.sqlite'), - entityPrefix, - migrations: sqliteMigrations, - migrationsRun: false, // migrations for sqlite will be ran manually for now; see below - migrationsTableName: `${entityPrefix}migrations`, - }; - break; - - default: - throw new Error(`The database "${dbType}" is currently not supported!`); + default: + throw new Error(`The database "${dbType}" is currently not supported!`); + } } let loggingOption: LoggerOptions = (await GenericHelpers.getConfigValue( @@ -129,9 +162,9 @@ export async function init(): Promise { )) as string, }); - let connection = await createConnection(connectionOptions); + connection = await createConnection(connectionOptions); - if (dbType === 'sqlite') { + if (!testConnectionOptions && dbType === 'sqlite') { // This specific migration changes database metadata. // A field is now nullable. We need to reconnect so that // n8n knows it has changed. Happens only on sqlite. @@ -157,11 +190,17 @@ export async function init(): Promise { } } - collections.Credentials = getRepository(entities.CredentialsEntity); - collections.Execution = getRepository(entities.ExecutionEntity); - collections.Workflow = getRepository(entities.WorkflowEntity); - collections.Webhook = getRepository(entities.WebhookEntity); - collections.Tag = getRepository(entities.TagEntity); + collections.Credentials = linkRepository(entities.CredentialsEntity); + collections.Execution = linkRepository(entities.ExecutionEntity); + collections.Workflow = linkRepository(entities.WorkflowEntity); + collections.Webhook = linkRepository(entities.WebhookEntity); + collections.Tag = linkRepository(entities.TagEntity); + + collections.Role = linkRepository(entities.Role); + collections.User = linkRepository(entities.User); + collections.SharedCredentials = linkRepository(entities.SharedCredentials); + collections.SharedWorkflow = linkRepository(entities.SharedWorkflow); + collections.Settings = linkRepository(entities.Settings); return collections; } diff --git a/packages/cli/src/GenericHelpers.ts b/packages/cli/src/GenericHelpers.ts index 73a2310929..9514c202d1 100644 --- a/packages/cli/src/GenericHelpers.ts +++ b/packages/cli/src/GenericHelpers.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/restrict-template-expressions */ /* eslint-disable @typescript-eslint/no-unsafe-return */ @@ -8,14 +9,18 @@ import * as express from 'express'; import { join as pathJoin } from 'path'; import { readFile as fsReadFile } from 'fs/promises'; import { IDataObject } from 'n8n-workflow'; +import { validate } from 'class-validator'; import * as config from '../config'; // eslint-disable-next-line import/no-cycle -import { Db, ICredentialsDb, IPackageVersions } from '.'; +import { Db, ICredentialsDb, IPackageVersions, ResponseHelper } from '.'; // eslint-disable-next-line import/order import { Like } from 'typeorm'; // eslint-disable-next-line import/no-cycle import { WorkflowEntity } from './databases/entities/WorkflowEntity'; +import { CredentialsEntity } from './databases/entities/CredentialsEntity'; +import { TagEntity } from './databases/entities/TagEntity'; +import { User } from './databases/entities/User'; let versionCache: IPackageVersions | undefined; @@ -188,3 +193,23 @@ export async function generateUniqueName( return { name: `${requestedName} ${maxSuffix + 1}` }; } + +export async function validateEntity( + entity: WorkflowEntity | CredentialsEntity | TagEntity | User, +): Promise { + const errors = await validate(entity); + + const errorMessages = errors + .reduce((acc, cur) => { + if (!cur.constraints) return acc; + acc.push(...Object.values(cur.constraints)); + return acc; + }, []) + .join(' | '); + + if (errorMessages) { + throw new ResponseHelper.ResponseError(errorMessages, undefined, 400); + } +} + +export const DEFAULT_EXECUTIONS_GET_ALL_LIMIT = 20; diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 69be4445ac..ac8290140c 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -29,6 +29,11 @@ import { Url } from 'url'; import { Request } from 'express'; import { WorkflowEntity } from './databases/entities/WorkflowEntity'; import { TagEntity } from './databases/entities/TagEntity'; +import { Role } from './databases/entities/Role'; +import { User } from './databases/entities/User'; +import { SharedCredentials } from './databases/entities/SharedCredentials'; +import { SharedWorkflow } from './databases/entities/SharedWorkflow'; +import { Settings } from './databases/entities/Settings'; export interface IActivationError { time: number; @@ -72,6 +77,11 @@ export interface IDatabaseCollections { Workflow: Repository | null; Webhook: Repository | null; Tag: Repository | null; + Role: Repository | null; + User: Repository | null; + SharedCredentials: Repository | null; + SharedWorkflow: Repository | null; + Settings: Repository | null; } export interface IWebhookDb { @@ -83,6 +93,16 @@ export interface IWebhookDb { pathLength?: number; } +// ---------------------------------- +// settings +// ---------------------------------- + +export interface ISettingsDb { + key: string; + value: string | boolean | IDataObject | number; + loadOnStartup: boolean; +} + // ---------------------------------- // tags // ---------------------------------- @@ -313,6 +333,16 @@ export interface IDiagnosticInfo { }; deploymentType: string; binaryDataMode: string; + n8n_multi_user_allowed: boolean; + smtp_set_up: boolean; +} + +export interface ITelemetryUserDeletionData { + user_id: string; + target_user_old_status: 'active' | 'invited'; + migration_strategy?: 'transfer_data' | 'delete_data'; + target_user_id?: string; + migration_user_id?: string; } export interface IInternalHooksClass { @@ -321,15 +351,29 @@ export interface IInternalHooksClass { diagnosticInfo: IDiagnosticInfo, firstWorkflowCreatedAt?: Date, ): Promise; - onPersonalizationSurveySubmitted(answers: IPersonalizationSurveyAnswers): Promise; - onWorkflowCreated(workflow: IWorkflowBase): Promise; - onWorkflowDeleted(workflowId: string): Promise; - onWorkflowSaved(workflow: IWorkflowBase): Promise; + onPersonalizationSurveySubmitted(userId: string, answers: Record): Promise; + onWorkflowCreated(userId: string, workflow: IWorkflowBase): Promise; + onWorkflowDeleted(userId: string, workflowId: string): Promise; + onWorkflowSaved(userId: string, workflow: IWorkflowBase): Promise; onWorkflowPostExecute( executionId: string, workflow: IWorkflowBase, runData?: IRun, + userId?: string, ): Promise; + onUserDeletion(userId: string, userDeletionData: ITelemetryUserDeletionData): Promise; + onUserInvite(userInviteData: { user_id: string; target_user_id: string[] }): Promise; + onUserReinvite(userReinviteData: { user_id: string; target_user_id: string }): Promise; + onUserUpdate(userUpdateData: { user_id: string; fields_changed: string[] }): Promise; + onUserInviteEmailClick(userInviteClickData: { user_id: string }): Promise; + onUserPasswordResetEmailClick(userPasswordResetData: { user_id: string }): Promise; + onUserTransactionalEmail(userTransactionalEmailData: { + user_id: string; + message_type: 'Reset password' | 'New user invite' | 'Resend invite'; + }): Promise; + onUserPasswordResetRequestClick(userPasswordResetData: { user_id: string }): Promise; + onInstanceOwnerSetup(instanceOwnerSetupData: { user_id: string }): Promise; + onUserSignup(userSignupData: { user_id: string }): Promise; } export interface IN8nConfig { @@ -402,6 +446,7 @@ export interface IN8nUISettings { }; timezone: string; urlBaseWebhook: string; + urlBaseEditor: string; versionCli: string; n8nMetadata?: { [key: string]: string | number | undefined; @@ -409,8 +454,10 @@ export interface IN8nUISettings { versionNotifications: IVersionNotificationSettings; instanceId: string; telemetry: ITelemetrySettings; - personalizationSurvey: IPersonalizationSurvey; + personalizationSurveyEnabled: boolean; defaultLocale: string; + userManagement: IUserManagementSettings; + workflowTagsDisabled: boolean; logLevel: 'info' | 'debug' | 'warn' | 'error' | 'verbose'; hiringBannerEnabled: boolean; templates: { @@ -428,9 +475,10 @@ export interface IPersonalizationSurveyAnswers { workArea: string[] | string | null; } -export interface IPersonalizationSurvey { - answers?: IPersonalizationSurveyAnswers; - shouldShow: boolean; +export interface IUserManagementSettings { + enabled: boolean; + showSetupOnFirstLoad?: boolean; + smtpSetup: boolean; } export interface IPackageVersions { @@ -556,6 +604,7 @@ export interface IWorkflowExecutionDataProcess { sessionId?: string; startNodes?: string[]; workflowData: IWorkflowBase; + userId: string; } export interface IWorkflowExecutionDataProcessWithExecution extends IWorkflowExecutionDataProcess { @@ -563,6 +612,7 @@ export interface IWorkflowExecutionDataProcessWithExecution extends IWorkflowExe credentialsTypeData: ICredentialsTypeData; executionId: string; nodeTypeData: ITransferNodeTypes; + userId: string; } export interface IWorkflowExecuteProcess { @@ -570,3 +620,5 @@ export interface IWorkflowExecuteProcess { workflow: Workflow; workflowExecute: WorkflowExecute; } + +export type WhereClause = Record; diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 2f48c77448..55a39ed88d 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -1,10 +1,11 @@ /* eslint-disable import/no-cycle */ import { BinaryDataManager } from 'n8n-core'; import { IDataObject, INodeTypes, IRun, TelemetryHelpers } from 'n8n-workflow'; +import { snakeCase } from 'change-case'; import { IDiagnosticInfo, IInternalHooksClass, - IPersonalizationSurveyAnswers, + ITelemetryUserDeletionData, IWorkflowBase, IWorkflowDb, } from '.'; @@ -34,6 +35,8 @@ export class InternalHooksClass implements IInternalHooksClass { execution_variables: diagnosticInfo.executionVariables, n8n_deployment_type: diagnosticInfo.deploymentType, n8n_binary_data_mode: diagnosticInfo.binaryDataMode, + n8n_multi_user_allowed: diagnosticInfo.n8n_multi_user_allowed, + smtp_set_up: diagnosticInfo.smtp_set_up, }; return Promise.all([ @@ -45,41 +48,49 @@ export class InternalHooksClass implements IInternalHooksClass { ]); } - async onPersonalizationSurveySubmitted(answers: IPersonalizationSurveyAnswers): Promise { - return this.telemetry.track('User responded to personalization questions', { - company_size: answers.companySize, - coding_skill: answers.codingSkill, - work_area: answers.workArea, - other_work_area: answers.otherWorkArea, - company_industry: answers.companyIndustry, - other_company_industry: answers.otherCompanyIndustry, + async onPersonalizationSurveySubmitted( + userId: string, + answers: Record, + ): Promise { + const camelCaseKeys = Object.keys(answers); + const personalizationSurveyData = { user_id: userId } as Record; + camelCaseKeys.forEach((camelCaseKey) => { + personalizationSurveyData[snakeCase(camelCaseKey)] = answers[camelCaseKey]; }); + + return this.telemetry.track( + 'User responded to personalization questions', + personalizationSurveyData, + ); } - async onWorkflowCreated(workflow: IWorkflowBase): Promise { + async onWorkflowCreated(userId: string, workflow: IWorkflowBase): Promise { const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); return this.telemetry.track('User created workflow', { + user_id: userId, workflow_id: workflow.id, node_graph: nodeGraph, node_graph_string: JSON.stringify(nodeGraph), }); } - async onWorkflowDeleted(workflowId: string): Promise { + async onWorkflowDeleted(userId: string, workflowId: string): Promise { return this.telemetry.track('User deleted workflow', { + user_id: userId, workflow_id: workflowId, }); } - async onWorkflowSaved(workflow: IWorkflowDb): Promise { + async onWorkflowSaved(userId: string, workflow: IWorkflowDb): Promise { const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); return this.telemetry.track('User saved workflow', { + user_id: userId, workflow_id: workflow.id, node_graph: nodeGraph, node_graph_string: JSON.stringify(nodeGraph), version_cli: this.versionCli, - num_tags: workflow.tags.length, + num_tags: workflow.tags?.length ?? 0, }); } @@ -87,6 +98,7 @@ export class InternalHooksClass implements IInternalHooksClass { executionId: string, workflow: IWorkflowBase, runData?: IRun, + userId?: string, ): Promise { const promises = [Promise.resolve()]; const properties: IDataObject = { @@ -95,6 +107,10 @@ export class InternalHooksClass implements IInternalHooksClass { version_cli: this.versionCli, }; + if (userId) { + properties.user_id = userId; + } + if (runData !== undefined) { properties.execution_mode = runData.mode; properties.success = !!runData.finished; @@ -188,4 +204,72 @@ export class InternalHooksClass implements IInternalHooksClass { return Promise.race([timeoutPromise, this.telemetry.trackN8nStop()]); } + + async onUserDeletion( + userId: string, + userDeletionData: ITelemetryUserDeletionData, + ): Promise { + return this.telemetry.track('User deleted user', { ...userDeletionData, user_id: userId }); + } + + async onUserInvite(userInviteData: { user_id: string; target_user_id: string[] }): Promise { + return this.telemetry.track('User invited new user', userInviteData); + } + + async onUserReinvite(userReinviteData: { + user_id: string; + target_user_id: string; + }): Promise { + return this.telemetry.track('User resent new user invite email', userReinviteData); + } + + async onUserUpdate(userUpdateData: { user_id: string; fields_changed: string[] }): Promise { + return this.telemetry.track('User changed personal settings', userUpdateData); + } + + async onUserInviteEmailClick(userInviteClickData: { user_id: string }): Promise { + return this.telemetry.track('User clicked invite link from email', userInviteClickData); + } + + async onUserPasswordResetEmailClick(userPasswordResetData: { user_id: string }): Promise { + return this.telemetry.track( + 'User clicked password reset link from email', + userPasswordResetData, + ); + } + + async onUserTransactionalEmail(userTransactionalEmailData: { + user_id: string; + message_type: 'Reset password' | 'New user invite' | 'Resend invite'; + }): Promise { + return this.telemetry.track( + 'Instance sent transactional email to user', + userTransactionalEmailData, + ); + } + + async onUserPasswordResetRequestClick(userPasswordResetData: { user_id: string }): Promise { + return this.telemetry.track( + 'User requested password reset while logged out', + userPasswordResetData, + ); + } + + async onInstanceOwnerSetup(instanceOwnerSetupData: { user_id: string }): Promise { + return this.telemetry.track('Owner finished instance setup', instanceOwnerSetupData); + } + + async onUserSignup(userSignupData: { user_id: string }): Promise { + return this.telemetry.track('User signed up', userSignupData); + } + + async onEmailFailed(failedEmailData: { + user_id: string; + message_type: 'Reset password' | 'New user invite' | 'Resend invite'; + }): Promise { + return this.telemetry.track( + 'Instance failed to send transactional email to user', + failedEmailData, + ); + } } diff --git a/packages/cli/src/PersonalizationSurvey.ts b/packages/cli/src/PersonalizationSurvey.ts deleted file mode 100644 index b384b4894c..0000000000 --- a/packages/cli/src/PersonalizationSurvey.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { readFileSync, writeFile } from 'fs'; -import { promisify } from 'util'; -import { UserSettings } from 'n8n-core'; - -import * as config from '../config'; -// eslint-disable-next-line import/no-cycle -import { Db, IPersonalizationSurvey, IPersonalizationSurveyAnswers } from '.'; - -const fsWriteFile = promisify(writeFile); - -const PERSONALIZATION_SURVEY_FILENAME = 'personalizationSurvey.json'; - -function loadSurveyFromDisk(): IPersonalizationSurveyAnswers | undefined { - const userSettingsPath = UserSettings.getUserN8nFolderPath(); - try { - const surveyFile = readFileSync( - `${userSettingsPath}/${PERSONALIZATION_SURVEY_FILENAME}`, - 'utf-8', - ); - return JSON.parse(surveyFile) as IPersonalizationSurveyAnswers; - } catch (error) { - return undefined; - } -} - -export async function writeSurveyToDisk( - surveyAnswers: IPersonalizationSurveyAnswers, -): Promise { - const userSettingsPath = UserSettings.getUserN8nFolderPath(); - await fsWriteFile( - `${userSettingsPath}/${PERSONALIZATION_SURVEY_FILENAME}`, - JSON.stringify(surveyAnswers, null, '\t'), - ); -} - -export async function preparePersonalizationSurvey(): Promise { - const survey: IPersonalizationSurvey = { - shouldShow: false, - }; - - survey.answers = loadSurveyFromDisk(); - - if (survey.answers) { - return survey; - } - - const enabled = - (config.get('personalization.enabled') as boolean) && - (config.get('diagnostics.enabled') as boolean); - - if (!enabled) { - return survey; - } - - const workflowsExist = !!(await Db.collections.Workflow?.findOne()); - - if (workflowsExist) { - return survey; - } - - survey.shouldShow = true; - return survey; -} diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index e8430c695d..747ca0211d 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable no-console */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ @@ -101,6 +102,8 @@ export function sendErrorResponse(res: Response, error: ResponseError, shouldLog httpStatusCode = error.httpStatusCode; } + shouldLog = !process.argv[1].split('/').includes('jest'); + if (process.env.NODE_ENV !== 'production' && shouldLog) { console.error('ERROR RESPONSE'); console.error(error); @@ -133,6 +136,9 @@ export function sendErrorResponse(res: Response, error: ResponseError, shouldLog res.status(httpStatusCode).json(response); } +const isUniqueConstraintError = (error: Error) => + ['unique', 'duplicate'].some((s) => error.message.toLowerCase().includes(s)); + /** * A helper function which does not just allow to return Promises it also makes sure that * all the responses have the same format @@ -148,10 +154,12 @@ export function send(processFunction: (req: Request, res: Response) => Promise { const enableMetrics = config.get('endpoints.metrics.enable') as boolean; let register: Registry; @@ -320,9 +376,6 @@ class App { this.frontendSettings.instanceId = await UserSettings.getInstanceId(); - this.frontendSettings.personalizationSurvey = - await PersonalizationSurvey.preparePersonalizationSurvey(); - await this.externalHooks.run('frontend.settings', [this.frontendSettings]); const excludeEndpoints = config.get('security.excludeEndpoints') as string; @@ -365,7 +418,11 @@ class App { this.app.use( async (req: express.Request, res: express.Response, next: express.NextFunction) => { - if (authIgnoreRegex.exec(req.url)) { + // Skip basic auth for a few listed endpoints or when instance owner has been setup + if ( + authIgnoreRegex.exec(req.url) || + config.get('userManagement.isInstanceOwnerSetUp') + ) { return next(); } const realm = 'n8n - Editor UI'; @@ -500,20 +557,32 @@ class App { }); } + // Parse cookies for easier access + this.app.use(cookieParser()); + // Get push connections - this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { - if (req.url.indexOf(`/${this.restEndpoint}/push`) === 0) { - // TODO: Later also has to add some kind of authentication token - if (req.query.sessionId === undefined) { - next(new Error('The query parameter "sessionId" is missing!')); + this.app.use( + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (req.url.indexOf(`/${this.restEndpoint}/push`) === 0) { + if (req.query.sessionId === undefined) { + next(new Error('The query parameter "sessionId" is missing!')); + return; + } + + try { + const authCookie = req.cookies?.[AUTH_COOKIE_NAME] ?? ''; + await resolveJwt(authCookie); + } catch (error) { + res.status(401).send('Unauthorized'); + return; + } + + this.push.add(req.query.sessionId as string, req, res); return; } - - this.push.add(req.query.sessionId as string, req, res); - return; - } - next(); - }); + next(); + }, + ); // Compress the response data this.app.use(compression()); @@ -593,6 +662,7 @@ class App { this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { // Allow access also from frontend when developing res.header('Access-Control-Allow-Origin', 'http://localhost:8080'); + res.header('Access-Control-Allow-Credentials', 'true'); res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE'); res.header( 'Access-Control-Allow-Headers', @@ -612,6 +682,13 @@ class App { next(); }); + // ---------------------------------------- + // User Management + // ---------------------------------------- + await userManagementRouter.addRoutes.apply(this, [ignoredEndpoints, this.restEndpoint]); + + this.app.use(`/${this.restEndpoint}/credentials`, credentialsController); + // ---------------------------------------- // Healthcheck // ---------------------------------------- @@ -663,42 +740,69 @@ class App { // Creates a new workflow this.app.post( `/${this.restEndpoint}/workflows`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - delete req.body.id; // ignore if sent by mistake - const incomingData = req.body; + ResponseHelper.send(async (req: WorkflowRequest.Create) => { + delete req.body.id; // delete if sent - const newWorkflow = new WorkflowEntity(); + const newWorkflow = new WorkflowEntity(); - Object.assign(newWorkflow, incomingData); - newWorkflow.name = incomingData.name.trim(); + Object.assign(newWorkflow, req.body); - const incomingTagOrder = incomingData.tags.slice(); + await validateEntity(newWorkflow); - if (incomingData.tags.length) { - newWorkflow.tags = await Db.collections.Tag!.findByIds(incomingData.tags, { - select: ['id', 'name'], - }); - } + await this.externalHooks.run('workflow.create', [newWorkflow]); - // check credentials for old format - await WorkflowHelpers.replaceInvalidCredentials(newWorkflow); + const { tags: tagIds } = req.body; - await this.externalHooks.run('workflow.create', [newWorkflow]); + if (tagIds?.length && !config.get('workflowTagsDisabled')) { + newWorkflow.tags = await Db.collections.Tag!.findByIds(tagIds, { + select: ['id', 'name'], + }); + } - await WorkflowHelpers.validateWorkflow(newWorkflow); - const savedWorkflow = (await Db.collections - .Workflow!.save(newWorkflow) - .catch(WorkflowHelpers.throwDuplicateEntryError)) as WorkflowEntity; - savedWorkflow.tags = TagHelpers.sortByRequestOrder(savedWorkflow.tags, incomingTagOrder); + await WorkflowHelpers.replaceInvalidCredentials(newWorkflow); - // @ts-ignore - savedWorkflow.id = savedWorkflow.id.toString(); - await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]); - void InternalHooksManager.getInstance().onWorkflowCreated(newWorkflow as IWorkflowBase); - return savedWorkflow; - }, - ), + let savedWorkflow: undefined | WorkflowEntity; + + await getConnection().transaction(async (transactionManager) => { + savedWorkflow = await transactionManager.save(newWorkflow); + + const role = await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'workflow', + }); + + const newSharedWorkflow = new SharedWorkflow(); + + Object.assign(newSharedWorkflow, { + role, + user: req.user, + workflow: savedWorkflow, + }); + + await transactionManager.save(newSharedWorkflow); + }); + + if (!savedWorkflow) { + LoggerProxy.error('Failed to create workflow', { userId: req.user.id }); + throw new ResponseHelper.ResponseError('Failed to save workflow'); + } + + if (tagIds && !config.get('workflowTagsDisabled')) { + savedWorkflow.tags = TagHelpers.sortByRequestOrder(savedWorkflow.tags, { + requestOrder: tagIds, + }); + } + + await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]); + void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, newWorkflow); + + const { id, ...rest } = savedWorkflow; + + return { + id: id.toString(), + ...rest, + }; + }), ); // Reads and returns workflow data from an URL @@ -757,192 +861,294 @@ class App { // Returns workflows this.app.get( `/${this.restEndpoint}/workflows`, - ResponseHelper.send(async (req: express.Request, res: express.Response) => { - const findQuery: FindManyOptions = { + ResponseHelper.send(async (req: WorkflowRequest.GetAll) => { + let workflows: WorkflowEntity[] = []; + + const filter: Record = req.query.filter ? JSON.parse(req.query.filter) : {}; + + const query: FindManyOptions = { select: ['id', 'name', 'active', 'createdAt', 'updatedAt'], relations: ['tags'], }; - if (req.query.filter) { - findQuery.where = JSON.parse(req.query.filter as string); + if (config.get('workflowTagsDisabled')) { + delete query.relations; } - const workflows = await Db.collections.Workflow!.find(findQuery); + if (req.user.globalRole.name === 'owner') { + workflows = await Db.collections.Workflow!.find( + Object.assign(query, { + where: filter, + }), + ); + } else { + const shared = await Db.collections.SharedWorkflow!.find({ + relations: ['workflow'], + where: whereClause({ + user: req.user, + entityType: 'workflow', + }), + }); - workflows.forEach((workflow) => { - // @ts-ignore - workflow.id = workflow.id.toString(); - // @ts-ignore - workflow.tags = workflow.tags.map(({ id, name }) => ({ id: id.toString(), name })); + if (!shared.length) return []; + + workflows = await Db.collections.Workflow!.find( + Object.assign(query, { + where: { + id: In(shared.map(({ workflow }) => workflow.id)), + ...filter, + }, + }), + ); + } + + return workflows.map((workflow) => { + const { id, ...rest } = workflow; + + return { + id: id.toString(), + ...rest, + }; }); - return workflows; }), ); this.app.get( `/${this.restEndpoint}/workflows/new`, - ResponseHelper.send( - async (req: NameRequest, res: express.Response): Promise<{ name: string }> => { - const requestedName = - req.query.name && req.query.name !== '' ? req.query.name : this.defaultWorkflowName; + ResponseHelper.send(async (req: WorkflowRequest.NewName) => { + const requestedName = + req.query.name && req.query.name !== '' ? req.query.name : this.defaultWorkflowName; - return await GenericHelpers.generateUniqueName(requestedName, 'workflow'); - }, - ), + return await GenericHelpers.generateUniqueName(requestedName, 'workflow'); + }), ); // Returns a specific workflow this.app.get( `/${this.restEndpoint}/workflows/:id`, - ResponseHelper.send( - async ( - req: express.Request, - res: express.Response, - ): Promise => { - const workflow = await Db.collections.Workflow!.findOne(req.params.id, { - relations: ['tags'], + ResponseHelper.send(async (req: WorkflowRequest.Get) => { + const { id: workflowId } = req.params; + + let relations = ['workflow', 'workflow.tags']; + + if (config.get('workflowTagsDisabled')) { + relations = relations.filter((relation) => relation !== 'workflow.tags'); + } + + const shared = await Db.collections.SharedWorkflow!.findOne({ + relations, + where: whereClause({ + user: req.user, + entityType: 'workflow', + entityId: workflowId, + }), + }); + + if (!shared) { + LoggerProxy.info('User attempted to access a workflow without permissions', { + workflowId, + userId: req.user.id, }); + throw new ResponseHelper.ResponseError( + `Workflow with ID "${workflowId}" could not be found.`, + undefined, + 404, + ); + } - if (workflow === undefined) { - return undefined; - } + const { + workflow: { id, ...rest }, + } = shared; - // @ts-ignore - workflow.id = workflow.id.toString(); - // @ts-ignore - workflow.tags.forEach((tag) => (tag.id = tag.id.toString())); - return workflow; - }, - ), + return { + id: id.toString(), + ...rest, + }; + }), ); // Updates an existing workflow this.app.patch( `/${this.restEndpoint}/workflows/:id`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - const { tags, ...updateData } = req.body; + ResponseHelper.send(async (req: WorkflowRequest.Update) => { + const { id: workflowId } = req.params; - const { id } = req.params; - updateData.id = id; + const updateData = new WorkflowEntity(); + const { tags, ...rest } = req.body; + Object.assign(updateData, rest); - // check credentials for old format - await WorkflowHelpers.replaceInvalidCredentials(updateData as WorkflowEntity); + const shared = await Db.collections.SharedWorkflow!.findOne({ + relations: ['workflow'], + where: whereClause({ + user: req.user, + entityType: 'workflow', + entityId: workflowId, + }), + }); - await this.externalHooks.run('workflow.update', [updateData]); + if (!shared) { + LoggerProxy.info('User attempted to update a workflow without permissions', { + workflowId, + userId: req.user.id, + }); + throw new ResponseHelper.ResponseError( + `Workflow with ID "${workflowId}" could not be found to be updated.`, + undefined, + 404, + ); + } - const isActive = await this.activeWorkflowRunner.isActive(id); + // check credentials for old format + await WorkflowHelpers.replaceInvalidCredentials(updateData); - if (isActive) { - // When workflow gets saved always remove it as the triggers could have been - // changed and so the changes would not take effect - await this.activeWorkflowRunner.remove(id); + await this.externalHooks.run('workflow.update', [updateData]); + + if (shared.workflow.active) { + // When workflow gets saved always remove it as the triggers could have been + // changed and so the changes would not take effect + await this.activeWorkflowRunner.remove(workflowId); + } + + if (updateData.settings) { + if (updateData.settings.timezone === 'DEFAULT') { + // Do not save the default timezone + delete updateData.settings.timezone; } - - if (updateData.settings) { - if (updateData.settings.timezone === 'DEFAULT') { - // Do not save the default timezone - delete updateData.settings.timezone; - } - if (updateData.settings.saveDataErrorExecution === 'DEFAULT') { - // Do not save when default got set - delete updateData.settings.saveDataErrorExecution; - } - if (updateData.settings.saveDataSuccessExecution === 'DEFAULT') { - // Do not save when default got set - delete updateData.settings.saveDataSuccessExecution; - } - if (updateData.settings.saveManualExecutions === 'DEFAULT') { - // Do not save when default got set - delete updateData.settings.saveManualExecutions; - } - if ( - parseInt(updateData.settings.executionTimeout as string, 10) === this.executionTimeout - ) { - // Do not save when default got set - delete updateData.settings.executionTimeout; - } + if (updateData.settings.saveDataErrorExecution === 'DEFAULT') { + // Do not save when default got set + delete updateData.settings.saveDataErrorExecution; } - - // required due to atomic update - updateData.updatedAt = this.getCurrentDate(); - - await WorkflowHelpers.validateWorkflow(updateData); - await Db.collections - .Workflow!.update(id, updateData) - .catch(WorkflowHelpers.throwDuplicateEntryError); - - if (tags) { - const tablePrefix = config.get('database.tablePrefix'); - await TagHelpers.removeRelations(req.params.id, tablePrefix); - - if (tags.length) { - await TagHelpers.createRelations(req.params.id, tags, tablePrefix); - } + if (updateData.settings.saveDataSuccessExecution === 'DEFAULT') { + // Do not save when default got set + delete updateData.settings.saveDataSuccessExecution; } + if (updateData.settings.saveManualExecutions === 'DEFAULT') { + // Do not save when default got set + delete updateData.settings.saveManualExecutions; + } + if ( + parseInt(updateData.settings.executionTimeout as string, 10) === this.executionTimeout + ) { + // Do not save when default got set + delete updateData.settings.executionTimeout; + } + } - // We sadly get nothing back from "update". Neither if it updated a record - // nor the new value. So query now the hopefully updated entry. - const workflow = await Db.collections.Workflow!.findOne(id, { relations: ['tags'] }); + if (updateData.name) { + updateData.updatedAt = this.getCurrentDate(); // required due to atomic update + await validateEntity(updateData); + } - if (workflow === undefined) { - throw new ResponseHelper.ResponseError( - `Workflow with id "${id}" could not be found to be updated.`, - undefined, - 400, + await Db.collections.Workflow!.update(workflowId, updateData); + + if (tags && !config.get('workflowTagsDisabled')) { + const tablePrefix = config.get('database.tablePrefix'); + await TagHelpers.removeRelations(workflowId, tablePrefix); + + if (tags.length) { + await TagHelpers.createRelations(workflowId, tags, tablePrefix); + } + } + + const options: FindManyOptions = { + relations: ['tags'], + }; + + if (config.get('workflowTagsDisabled')) { + delete options.relations; + } + + // We sadly get nothing back from "update". Neither if it updated a record + // nor the new value. So query now the hopefully updated entry. + const updatedWorkflow = await Db.collections.Workflow!.findOne(workflowId, options); + + if (updatedWorkflow === undefined) { + throw new ResponseHelper.ResponseError( + `Workflow with ID "${workflowId}" could not be found to be updated.`, + undefined, + 400, + ); + } + + if (updatedWorkflow.tags.length && tags?.length) { + updatedWorkflow.tags = TagHelpers.sortByRequestOrder(updatedWorkflow.tags, { + requestOrder: tags, + }); + } + + await this.externalHooks.run('workflow.afterUpdate', [updatedWorkflow]); + // @ts-ignore + void InternalHooksManager.getInstance().onWorkflowSaved(req.user.id, updatedWorkflow); + + if (updatedWorkflow.active) { + // When the workflow is supposed to be active add it again + try { + await this.externalHooks.run('workflow.activate', [updatedWorkflow]); + await this.activeWorkflowRunner.add( + workflowId, + shared.workflow.active ? 'update' : 'activate', ); + } catch (error) { + // If workflow could not be activated set it again to inactive + updateData.active = false; + // @ts-ignore + await Db.collections.Workflow!.update(workflowId, updateData); + + // Also set it in the returned data + updatedWorkflow.active = false; + + // Now return the original error for UI to display + throw error; } + } - if (tags?.length) { - workflow.tags = TagHelpers.sortByRequestOrder(workflow.tags, tags); - } + const { id, ...remainder } = updatedWorkflow; - await this.externalHooks.run('workflow.afterUpdate', [workflow]); - void InternalHooksManager.getInstance().onWorkflowSaved(workflow); - - if (workflow.active) { - // When the workflow is supposed to be active add it again - try { - await this.externalHooks.run('workflow.activate', [workflow]); - await this.activeWorkflowRunner.add(id, isActive ? 'update' : 'activate'); - } catch (error) { - // If workflow could not be activated set it again to inactive - updateData.active = false; - // @ts-ignore - await Db.collections.Workflow!.update(id, updateData); - - // Also set it in the returned data - workflow.active = false; - - // Now return the original error for UI to display - throw error; - } - } - - // @ts-ignore - workflow.id = workflow.id.toString(); - return workflow; - }, - ), + return { + id: id.toString(), + ...remainder, + }; + }), ); // Deletes a specific workflow this.app.delete( `/${this.restEndpoint}/workflows/:id`, - ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - const { id } = req.params; + ResponseHelper.send(async (req: WorkflowRequest.Delete) => { + const { id: workflowId } = req.params; - await this.externalHooks.run('workflow.delete', [id]); + await this.externalHooks.run('workflow.delete', [workflowId]); - const isActive = await this.activeWorkflowRunner.isActive(id); - if (isActive) { - // Before deleting a workflow deactivate it - await this.activeWorkflowRunner.remove(id); + const shared = await Db.collections.SharedWorkflow!.findOne({ + relations: ['workflow'], + where: whereClause({ + user: req.user, + entityType: 'workflow', + entityId: workflowId, + }), + }); + + if (!shared) { + LoggerProxy.info('User attempted to delete a workflow without permissions', { + workflowId, + userId: req.user.id, + }); + throw new ResponseHelper.ResponseError( + `Workflow with ID "${workflowId}" could not be found to be deleted.`, + undefined, + 400, + ); } - await Db.collections.Workflow!.delete(id); - void InternalHooksManager.getInstance().onWorkflowDeleted(id); - await this.externalHooks.run('workflow.afterDelete', [id]); + if (shared.workflow.active) { + // deactivate before deleting + await this.activeWorkflowRunner.remove(workflowId); + } + + await Db.collections.Workflow!.delete(workflowId); + + void InternalHooksManager.getInstance().onWorkflowDeleted(req.user.id, workflowId); + await this.externalHooks.run('workflow.afterDelete', [workflowId]); return true; }), @@ -951,7 +1157,10 @@ class App { this.app.post( `/${this.restEndpoint}/workflows/run`, ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { + async ( + req: WorkflowRequest.ManualRun, + res: express.Response, + ): Promise => { const { workflowData } = req.body; const { runData } = req.body; const { startNodes } = req.body; @@ -968,13 +1177,13 @@ class App { startNodes.length === 0 || destinationNode === undefined ) { - const additionalData = await WorkflowExecuteAdditionalData.getBase(); + const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id); const nodeTypes = NodeTypes(); const workflowInstance = new Workflow({ - id: workflowData.id, + id: workflowData.id?.toString(), name: workflowData.name, - nodes: workflowData.nodes, - connections: workflowData.connections, + nodes: workflowData.nodes!, + connections: workflowData.connections!, active: false, nodeTypes, staticData: undefined, @@ -1007,6 +1216,7 @@ class App { sessionId, startNodes, workflowData, + userId: req.user.id, }; const workflowRunner = new WorkflowRunner(); const executionId = await workflowRunner.run(data); @@ -1026,15 +1236,15 @@ class App { req: express.Request, res: express.Response, ): Promise => { + if (config.get('workflowTagsDisabled')) { + throw new ResponseHelper.ResponseError('Workflow tags are disabled'); + } if (req.query.withUsageCount === 'true') { const tablePrefix = config.get('database.tablePrefix'); return TagHelpers.getTagsWithCountDb(tablePrefix); } - const tags = await Db.collections.Tag!.find({ select: ['id', 'name'] }); - // @ts-ignore - tags.forEach((tag) => (tag.id = tag.id.toString())); - return tags; + return Db.collections.Tag!.find({ select: ['id', 'name'] }); }, ), ); @@ -1044,20 +1254,19 @@ class App { `/${this.restEndpoint}/tags`, ResponseHelper.send( async (req: express.Request, res: express.Response): Promise => { + if (config.get('workflowTagsDisabled')) { + throw new ResponseHelper.ResponseError('Workflow tags are disabled'); + } const newTag = new TagEntity(); newTag.name = req.body.name.trim(); await this.externalHooks.run('tag.beforeCreate', [newTag]); - await TagHelpers.validateTag(newTag); - const tag = await Db.collections - .Tag!.save(newTag) - .catch(TagHelpers.throwDuplicateEntryError); + await validateEntity(newTag); + const tag = await Db.collections.Tag!.save(newTag); await this.externalHooks.run('tag.afterCreate', [tag]); - // @ts-ignore - tag.id = tag.id.toString(); return tag; }, ), @@ -1068,24 +1277,25 @@ class App { `/${this.restEndpoint}/tags/:id`, ResponseHelper.send( async (req: express.Request, res: express.Response): Promise => { + if (config.get('workflowTagsDisabled')) { + throw new ResponseHelper.ResponseError('Workflow tags are disabled'); + } + const { name } = req.body; const { id } = req.params; const newTag = new TagEntity(); - newTag.id = Number(id); + // @ts-ignore + newTag.id = id; newTag.name = name.trim(); await this.externalHooks.run('tag.beforeUpdate', [newTag]); - await TagHelpers.validateTag(newTag); - const tag = await Db.collections - .Tag!.save(newTag) - .catch(TagHelpers.throwDuplicateEntryError); + await validateEntity(newTag); + const tag = await Db.collections.Tag!.save(newTag); await this.externalHooks.run('tag.afterUpdate', [tag]); - // @ts-ignore - tag.id = tag.id.toString(); return tag; }, ), @@ -1094,17 +1304,33 @@ class App { // Deletes a tag this.app.delete( `/${this.restEndpoint}/tags/:id`, - ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - const id = Number(req.params.id); + ResponseHelper.send( + async (req: TagsRequest.Delete, res: express.Response): Promise => { + if (config.get('workflowTagsDisabled')) { + throw new ResponseHelper.ResponseError('Workflow tags are disabled'); + } + if ( + config.get('userManagement.isInstanceOwnerSetUp') === true && + req.user.globalRole.name !== 'owner' + ) { + throw new ResponseHelper.ResponseError( + 'You are not allowed to perform this action', + undefined, + 403, + 'Only owners can remove tags', + ); + } + const id = Number(req.params.id); - await this.externalHooks.run('tag.beforeDelete', [id]); + await this.externalHooks.run('tag.beforeDelete', [id]); - await Db.collections.Tag!.delete({ id }); + await Db.collections.Tag!.delete({ id }); - await this.externalHooks.run('tag.afterDelete', [id]); + await this.externalHooks.run('tag.afterDelete', [id]); - return true; - }), + return true; + }, + ), ); // Returns parameter values which normally get loaded from an external API or @@ -1112,40 +1338,43 @@ class App { this.app.get( `/${this.restEndpoint}/node-parameter-options`, ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { + async (req: NodeParameterOptionsRequest): Promise => { const nodeTypeAndVersion = JSON.parse( - `${req.query.nodeTypeAndVersion}`, + req.query.nodeTypeAndVersion, ) as INodeTypeNameVersion; - const path = req.query.path as string; - let credentials: INodeCredentials | undefined; + + const { path, methodName } = req.query; + const currentNodeParameters = JSON.parse( - `${req.query.currentNodeParameters}`, + req.query.currentNodeParameters, ) as INodeParameters; - if (req.query.credentials !== undefined) { - credentials = JSON.parse(req.query.credentials as string); + + let credentials: INodeCredentials | undefined; + + if (req.query.credentials) { + credentials = JSON.parse(req.query.credentials); } - const nodeTypes = NodeTypes(); - - // @ts-ignore const loadDataInstance = new LoadNodeParameterOptions( nodeTypeAndVersion, - nodeTypes, + NodeTypes(), path, currentNodeParameters, credentials, ); - const additionalData = await WorkflowExecuteAdditionalData.getBase(currentNodeParameters); + const additionalData = await WorkflowExecuteAdditionalData.getBase( + req.user.id, + currentNodeParameters, + ); - if (req.query.methodName) { - return loadDataInstance.getOptionsViaMethodName( - req.query.methodName as string, - additionalData, - ); + if (methodName) { + return loadDataInstance.getOptionsViaMethodName(methodName, additionalData); } + // @ts-ignore if (req.query.loadOptions) { return loadDataInstance.getOptionsViaRequestProperty( + // @ts-ignore JSON.parse(req.query.loadOptions as string), additionalData, ); @@ -1340,314 +1569,45 @@ class App { // Returns the active workflow ids this.app.get( `/${this.restEndpoint}/active`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - const activeWorkflows = await this.activeWorkflowRunner.getActiveWorkflows(); - return activeWorkflows.map((workflow) => workflow.id.toString()); - }, - ), + ResponseHelper.send(async (req: WorkflowRequest.GetAllActive) => { + const activeWorkflows = await this.activeWorkflowRunner.getActiveWorkflows(req.user); + + return activeWorkflows.map(({ id }) => id.toString()); + }), ); // Returns if the workflow with the given id had any activation errors this.app.get( `/${this.restEndpoint}/active/error/:id`, - ResponseHelper.send( - async ( - req: express.Request, - res: express.Response, - ): Promise => { - const { id } = req.params; - return this.activeWorkflowRunner.getActivationError(id); - }, - ), - ); + ResponseHelper.send(async (req: WorkflowRequest.GetAllActivationErrors) => { + const { id: workflowId } = req.params; - // ---------------------------------------- - // Credentials - // ---------------------------------------- + const shared = await Db.collections.SharedWorkflow!.findOne({ + relations: ['workflow'], + where: whereClause({ + user: req.user, + entityType: 'workflow', + entityId: workflowId, + }), + }); - this.app.get( - `/${this.restEndpoint}/credentials/new`, - ResponseHelper.send( - async (req: NameRequest, res: express.Response): Promise<{ name: string }> => { - const requestedName = - req.query.name && req.query.name !== '' ? req.query.name : this.defaultCredentialsName; + if (!shared) { + LoggerProxy.info('User attempted to access workflow errors without permissions', { + workflowId, + userId: req.user.id, + }); - return await GenericHelpers.generateUniqueName(requestedName, 'credentials'); - }, - ), - ); + throw new ResponseHelper.ResponseError( + `Workflow with ID "${workflowId}" could not be found.`, + undefined, + 400, + ); + } - // Deletes a specific credential - this.app.delete( - `/${this.restEndpoint}/credentials/:id`, - ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - const { id } = req.params; - - await this.externalHooks.run('credentials.delete', [id]); - - await Db.collections.Credentials!.delete({ id }); - - return true; + return this.activeWorkflowRunner.getActivationError(workflowId); }), ); - // Creates new credentials - this.app.post( - `/${this.restEndpoint}/credentials`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - const incomingData = req.body; - - if (!incomingData.name || incomingData.name.length < 3) { - throw new ResponseHelper.ResponseError( - `Credentials name must be at least 3 characters long.`, - undefined, - 400, - ); - } - - // Add the added date for node access permissions - for (const nodeAccess of incomingData.nodesAccess) { - nodeAccess.date = this.getCurrentDate(); - } - - const encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { - throw new Error('No encryption key got found to encrypt the credentials!'); - } - - if (incomingData.name === '') { - throw new Error('Credentials have to have a name set!'); - } - - // Encrypt the data - const credentials = new Credentials( - { id: null, name: incomingData.name }, - incomingData.type, - incomingData.nodesAccess, - ); - credentials.setData(incomingData.data, encryptionKey); - const newCredentialsData = credentials.getDataToSave() as ICredentialsDb; - - await this.externalHooks.run('credentials.create', [newCredentialsData]); - - // Save the credentials in DB - const result = await Db.collections.Credentials!.save(newCredentialsData); - result.data = incomingData.data; - - // Convert to response format in which the id is a string - (result as unknown as ICredentialsResponse).id = result.id.toString(); - return result as unknown as ICredentialsResponse; - }, - ), - ); - - // Test credentials - this.app.post( - `/${this.restEndpoint}/credentials-test`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - const incomingData = req.body as INodeCredentialTestRequest; - - const encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { - return { - status: 'Error', - message: 'No encryption key got found to decrypt the credentials!', - }; - } - - const credentialsHelper = new CredentialsHelper(encryptionKey); - - const credentialType = incomingData.credentials.type; - return credentialsHelper.testCredentials( - credentialType, - incomingData.credentials, - incomingData.nodeToTestWith, - ); - }, - ), - ); - - // Updates existing credentials - this.app.patch( - `/${this.restEndpoint}/credentials/:id`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - const incomingData = req.body; - - const { id } = req.params; - - if (incomingData.name === '') { - throw new Error('Credentials have to have a name set!'); - } - - // Add the date for newly added node access permissions - for (const nodeAccess of incomingData.nodesAccess) { - if (!nodeAccess.date) { - nodeAccess.date = this.getCurrentDate(); - } - } - - const encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { - throw new Error('No encryption key got found to encrypt the credentials!'); - } - - // Load the currently saved credentials to be able to persist some of the data if - const result = await Db.collections.Credentials!.findOne(id); - if (result === undefined) { - throw new ResponseHelper.ResponseError( - `Credentials with the id "${id}" do not exist.`, - undefined, - 400, - ); - } - - const currentlySavedCredentials = new Credentials( - result as INodeCredentialsDetails, - result.type, - result.nodesAccess, - result.data, - ); - const decryptedData = currentlySavedCredentials.getData(encryptionKey); - - // Do not overwrite the oauth data else data like the access or refresh token would get lost - // everytime anybody changes anything on the credentials even if it is just the name. - if (decryptedData.oauthTokenData) { - incomingData.data.oauthTokenData = decryptedData.oauthTokenData; - } - - // Encrypt the data - const credentials = new Credentials( - { id, name: incomingData.name }, - incomingData.type, - incomingData.nodesAccess, - ); - credentials.setData(incomingData.data, encryptionKey); - const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; - - // Add special database related data - newCredentialsData.updatedAt = this.getCurrentDate(); - - await this.externalHooks.run('credentials.update', [newCredentialsData]); - - // Update the credentials in DB - await Db.collections.Credentials!.update(id, newCredentialsData); - - // We sadly get nothing back from "update". Neither if it updated a record - // nor the new value. So query now the hopefully updated entry. - const responseData = await Db.collections.Credentials!.findOne(id); - - if (responseData === undefined) { - throw new ResponseHelper.ResponseError( - `Credentials with id "${id}" could not be found to be updated.`, - undefined, - 400, - ); - } - - // Remove the encrypted data as it is not needed in the frontend - responseData.data = ''; - - // Convert to response format in which the id is a string - (responseData as unknown as ICredentialsResponse).id = responseData.id.toString(); - return responseData as unknown as ICredentialsResponse; - }, - ), - ); - - // Returns specific credentials - this.app.get( - `/${this.restEndpoint}/credentials/:id`, - ResponseHelper.send( - async ( - req: express.Request, - res: express.Response, - ): Promise => { - const findQuery = {} as FindManyOptions; - - // Make sure the variable has an expected value - const includeData = ['true', true].includes(req.query.includeData as string); - - if (!includeData) { - // Return only the fields we need - findQuery.select = ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt']; - } - - const result = await Db.collections.Credentials!.findOne(req.params.id); - - if (result === undefined) { - return result; - } - - let encryptionKey; - if (includeData) { - encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { - throw new Error('No encryption key got found to decrypt the credentials!'); - } - - const credentials = new Credentials( - result as INodeCredentialsDetails, - result.type, - result.nodesAccess, - result.data, - ); - (result as ICredentialsDecryptedDb).data = credentials.getData(encryptionKey); - } - - (result as ICredentialsDecryptedResponse).id = result.id.toString(); - - return result as ICredentialsDecryptedResponse; - }, - ), - ); - - // Returns all the saved credentials - this.app.get( - `/${this.restEndpoint}/credentials`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - const findQuery = {} as FindManyOptions; - if (req.query.filter) { - findQuery.where = JSON.parse(req.query.filter as string) as IDataObject; - if (findQuery.where.id !== undefined) { - // No idea if multiple where parameters make db search - // slower but to be sure that that is not the case we - // remove all unnecessary fields in case the id is defined. - // @ts-ignore - findQuery.where = { id: findQuery.where.id }; - } - } - - findQuery.select = ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt']; - - const results = (await Db.collections.Credentials!.find( - findQuery, - )) as unknown as ICredentialsResponse[]; - - let encryptionKey; - - const includeData = ['true', true].includes(req.query.includeData as string); - if (includeData) { - encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { - throw new Error('No encryption key got found to decrypt the credentials!'); - } - } - - let result; - for (result of results) { - (result as ICredentialsDecryptedResponse).id = result.id.toString(); - } - - return results; - }, - ), - ); - // ---------------------------------------- // Credential-Types // ---------------------------------------- @@ -1713,36 +1673,53 @@ class App { // Authorize OAuth Data this.app.get( `/${this.restEndpoint}/oauth1-credential/auth`, - ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - if (req.query.id === undefined) { - res.status(500).send('Required credential id is missing!'); - return ''; + ResponseHelper.send(async (req: OAuthRequest.OAuth1Credential.Auth): Promise => { + const { id: credentialId } = req.query; + + if (!credentialId) { + LoggerProxy.error('OAuth1 credential authorization failed due to missing credential ID'); + throw new ResponseHelper.ResponseError( + 'Required credential ID is missing', + undefined, + 400, + ); } - const result = await Db.collections.Credentials!.findOne(req.query.id as string); - if (result === undefined) { - res.status(404).send('The credential is not known.'); - return ''; + const credential = await getCredentialForUser(credentialId, req.user); + + if (!credential) { + LoggerProxy.error( + 'OAuth1 credential authorization failed because the current user does not have the correct permissions', + { userId: req.user.id }, + ); + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL, + undefined, + 404, + ); } - let encryptionKey; - encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { - res.status(500).send('No encryption key got found to decrypt the credentials!'); - return ''; + const encryptionKey = await UserSettings.getEncryptionKey(); + if (!encryptionKey) { + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + undefined, + 500, + ); } const mode: WorkflowExecuteMode = 'internal'; const credentialsHelper = new CredentialsHelper(encryptionKey); const decryptedDataOriginal = await credentialsHelper.getDecrypted( - result as INodeCredentialsDetails, - result.type, + credential as INodeCredentialsDetails, + credential.type, mode, true, ); + const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites( decryptedDataOriginal, - result.type, + credential.type, mode, ); @@ -1764,7 +1741,7 @@ class App { const oauthRequestData = { oauth_callback: `${WebhookHelpers.getWebhookBaseUrl()}${ this.restEndpoint - }/oauth1-credential/callback?cid=${req.query.id}`, + }/oauth1-credential/callback?cid=${credentialId}`, }; await this.externalHooks.run('oauth1.authenticate', [oAuthOptions, oauthRequestData]); @@ -1795,9 +1772,9 @@ class App { // Encrypt the data const credentials = new Credentials( - result as INodeCredentialsDetails, - result.type, - result.nodesAccess, + credential as INodeCredentialsDetails, + credential.type, + credential.nodesAccess, ); credentials.setData(decryptedDataOriginal, encryptionKey); @@ -1807,7 +1784,12 @@ class App { newCredentialsData.updatedAt = this.getCurrentDate(); // Update the credentials in DB - await Db.collections.Credentials!.update(req.query.id as string, newCredentialsData); + await Db.collections.Credentials!.update(credentialId, newCredentialsData); + + LoggerProxy.verbose('OAuth1 authorization successful for new credential', { + userId: req.user.id, + credentialId, + }); return returnUri; }), @@ -1816,11 +1798,11 @@ class App { // Verify and store app code. Generate access tokens and store for respective credential. this.app.get( `/${this.restEndpoint}/oauth1-credential/callback`, - async (req: express.Request, res: express.Response) => { + async (req: OAuthRequest.OAuth1Credential.Callback, res: express.Response) => { try { - const { oauth_verifier, oauth_token, cid } = req.query; + const { oauth_verifier, oauth_token, cid: credentialId } = req.query; - if (oauth_verifier === undefined || oauth_token === undefined) { + if (!oauth_verifier || !oauth_token) { const errorResponse = new ResponseHelper.ResponseError( `Insufficient parameters for OAuth1 callback. Received following query parameters: ${JSON.stringify( req.query, @@ -1828,24 +1810,36 @@ class App { undefined, 503, ); + LoggerProxy.error( + 'OAuth1 callback failed because of insufficient parameters received', + { + userId: req.user.id, + credentialId, + }, + ); return ResponseHelper.sendErrorResponse(res, errorResponse); } - const result = await Db.collections.Credentials!.findOne(cid as any); - if (result === undefined) { + const credential = await getCredentialForUser(credentialId, req.user); + + if (!credential) { + LoggerProxy.error('OAuth1 callback failed because of insufficient user permissions', { + userId: req.user.id, + credentialId, + }); const errorResponse = new ResponseHelper.ResponseError( - 'The credential is not known.', + RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL, undefined, 404, ); return ResponseHelper.sendErrorResponse(res, errorResponse); } - let encryptionKey; - encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { const errorResponse = new ResponseHelper.ResponseError( - 'No encryption key got found to decrypt the credentials!', + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, undefined, 503, ); @@ -1855,14 +1849,14 @@ class App { const mode: WorkflowExecuteMode = 'internal'; const credentialsHelper = new CredentialsHelper(encryptionKey); const decryptedDataOriginal = await credentialsHelper.getDecrypted( - result as INodeCredentialsDetails, - result.type, + credential as INodeCredentialsDetails, + credential.type, mode, true, ); const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites( decryptedDataOriginal, - result.type, + credential.type, mode, ); @@ -1880,6 +1874,10 @@ class App { try { oauthToken = await requestPromise(options); } catch (error) { + LoggerProxy.error('Unable to fetch tokens for OAuth1 callback', { + userId: req.user.id, + credentialId, + }); const errorResponse = new ResponseHelper.ResponseError( 'Unable to get access tokens!', undefined, @@ -1895,19 +1893,27 @@ class App { decryptedDataOriginal.oauthTokenData = oauthTokenJson; const credentials = new Credentials( - result as INodeCredentialsDetails, - result.type, - result.nodesAccess, + credential as INodeCredentialsDetails, + credential.type, + credential.nodesAccess, ); credentials.setData(decryptedDataOriginal, encryptionKey); const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; // Add special database related data newCredentialsData.updatedAt = this.getCurrentDate(); // Save the credentials in DB - await Db.collections.Credentials!.update(cid as any, newCredentialsData); + await Db.collections.Credentials!.update(credentialId, newCredentialsData); + LoggerProxy.verbose('OAuth1 callback successful for new credential', { + userId: req.user.id, + credentialId, + }); res.sendFile(pathResolve(__dirname, '../../templates/oauth-callback.html')); } catch (error) { + LoggerProxy.error('OAuth1 callback failed because of insufficient user permissions', { + userId: req.user.id, + credentialId: req.query.cid, + }); // Error response return ResponseHelper.sendErrorResponse(res, error); } @@ -1921,36 +1927,52 @@ class App { // Authorize OAuth Data this.app.get( `/${this.restEndpoint}/oauth2-credential/auth`, - ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - if (req.query.id === undefined) { - res.status(500).send('Required credential id is missing.'); - return ''; + ResponseHelper.send(async (req: OAuthRequest.OAuth2Credential.Auth): Promise => { + const { id: credentialId } = req.query; + + if (!credentialId) { + throw new ResponseHelper.ResponseError( + 'Required credential ID is missing', + undefined, + 400, + ); } - const result = await Db.collections.Credentials!.findOne(req.query.id as string); - if (result === undefined) { - res.status(404).send('The credential is not known.'); - return ''; + const credential = await getCredentialForUser(credentialId, req.user); + + if (!credential) { + LoggerProxy.error('Failed to authorize OAuth2 due to lack of permissions', { + userId: req.user.id, + credentialId, + }); + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL, + undefined, + 404, + ); } - let encryptionKey; - encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { - res.status(500).send('No encryption key got found to decrypt the credentials!'); - return ''; + const encryptionKey = await UserSettings.getEncryptionKey(); + if (!encryptionKey) { + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + undefined, + 500, + ); } const mode: WorkflowExecuteMode = 'internal'; const credentialsHelper = new CredentialsHelper(encryptionKey); const decryptedDataOriginal = await credentialsHelper.getDecrypted( - result as INodeCredentialsDetails, - result.type, + credential as INodeCredentialsDetails, + credential.type, mode, true, ); + const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites( decryptedDataOriginal, - result.type, + credential.type, mode, ); @@ -1981,9 +2003,9 @@ class App { // Encrypt the data const credentials = new Credentials( - result as INodeCredentialsDetails, - result.type, - result.nodesAccess, + credential as INodeCredentialsDetails, + credential.type, + credential.nodesAccess, ); decryptedDataOriginal.csrfSecret = csrfSecret; @@ -2010,6 +2032,10 @@ class App { returnUri += `&${authQueryParameters}`; } + LoggerProxy.verbose('OAuth2 authentication successful for new credential', { + userId: req.user.id, + credentialId, + }); return returnUri; }), ); @@ -2021,12 +2047,12 @@ class App { // Verify and store app code. Generate access tokens and store for respective credential. this.app.get( `/${this.restEndpoint}/oauth2-credential/callback`, - async (req: express.Request, res: express.Response) => { + async (req: OAuthRequest.OAuth2Credential.Callback, res: express.Response) => { try { // realmId it's currently just use for the quickbook OAuth2 flow const { code, state: stateEncoded } = req.query; - if (code === undefined || stateEncoded === undefined) { + if (!code || !stateEncoded) { const errorResponse = new ResponseHelper.ResponseError( `Insufficient parameters for OAuth2 callback. Received following query parameters: ${JSON.stringify( req.query, @@ -2039,7 +2065,7 @@ class App { let state; try { - state = JSON.parse(Buffer.from(stateEncoded as string, 'base64').toString()); + state = JSON.parse(Buffer.from(stateEncoded, 'base64').toString()); } catch (error) { const errorResponse = new ResponseHelper.ResponseError( 'Invalid state format returned', @@ -2049,21 +2075,26 @@ class App { return ResponseHelper.sendErrorResponse(res, errorResponse); } - const result = await Db.collections.Credentials!.findOne(state.cid); - if (result === undefined) { + const credential = await getCredentialForUser(state.cid, req.user); + + if (!credential) { + LoggerProxy.error('OAuth2 callback failed because of insufficient permissions', { + userId: req.user.id, + credentialId: state.cid, + }); const errorResponse = new ResponseHelper.ResponseError( - 'The credential is not known.', + RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL, undefined, 404, ); return ResponseHelper.sendErrorResponse(res, errorResponse); } - let encryptionKey; - encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { const errorResponse = new ResponseHelper.ResponseError( - 'No encryption key got found to decrypt the credentials!', + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, undefined, 503, ); @@ -2073,14 +2104,14 @@ class App { const mode: WorkflowExecuteMode = 'internal'; const credentialsHelper = new CredentialsHelper(encryptionKey); const decryptedDataOriginal = await credentialsHelper.getDecrypted( - result as INodeCredentialsDetails, - result.type, + credential as INodeCredentialsDetails, + credential.type, mode, true, ); const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites( decryptedDataOriginal, - result.type, + credential.type, mode, ); @@ -2089,6 +2120,10 @@ class App { decryptedDataOriginal.csrfSecret === undefined || !token.verify(decryptedDataOriginal.csrfSecret as string, state.token) ) { + LoggerProxy.debug('OAuth2 callback state is invalid', { + userId: req.user.id, + credentialId: state.cid, + }); const errorResponse = new ResponseHelper.ResponseError( 'The OAuth2 callback state is invalid!', undefined, @@ -2136,6 +2171,10 @@ class App { } if (oauthToken === undefined) { + LoggerProxy.error('OAuth2 callback failed: unable to get access tokens', { + userId: req.user.id, + credentialId: state.cid, + }); const errorResponse = new ResponseHelper.ResponseError( 'Unable to get access tokens!', undefined, @@ -2156,9 +2195,9 @@ class App { _.unset(decryptedDataOriginal, 'csrfSecret'); const credentials = new Credentials( - result as INodeCredentialsDetails, - result.type, - result.nodesAccess, + credential as INodeCredentialsDetails, + credential.type, + credential.nodesAccess, ); credentials.setData(decryptedDataOriginal, encryptionKey); const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; @@ -2166,6 +2205,10 @@ class App { newCredentialsData.updatedAt = this.getCurrentDate(); // Save the credentials in DB await Db.collections.Credentials!.update(state.cid, newCredentialsData); + LoggerProxy.verbose('OAuth2 callback successful for new credential', { + userId: req.user.id, + credentialId: state.cid, + }); res.sendFile(pathResolve(__dirname, '../../templates/oauth-callback.html')); } catch (error) { @@ -2183,107 +2226,113 @@ class App { this.app.get( `/${this.restEndpoint}/executions`, ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - let filter: any = {}; + async (req: ExecutionRequest.GetAll): Promise => { + const filter = req.query.filter ? JSON.parse(req.query.filter) : {}; - if (req.query.filter) { - filter = JSON.parse(req.query.filter as string); - } - - let limit = 20; - if (req.query.limit) { - limit = parseInt(req.query.limit as string, 10); - } + const limit = req.query.limit + ? parseInt(req.query.limit, 10) + : DEFAULT_EXECUTIONS_GET_ALL_LIMIT; const executingWorkflowIds: string[] = []; if (config.get('executions.mode') === 'queue') { const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']); - executingWorkflowIds.push( - ...(currentJobs.map((job) => job.data.executionId) as string[]), - ); + executingWorkflowIds.push(...currentJobs.map(({ data }) => data.executionId)); } + // We may have manual executions even with queue so we must account for these. executingWorkflowIds.push( - ...this.activeExecutionsInstance - .getActiveExecutions() - .map((execution) => execution.id.toString()), + ...this.activeExecutionsInstance.getActiveExecutions().map(({ id }) => id), ); - const countFilter = JSON.parse(JSON.stringify(filter)); - if (countFilter.waitTill !== undefined) { - countFilter.waitTill = Not(IsNull()); - } + const countFilter = cloneDeep(filter); + countFilter.waitTill &&= Not(IsNull()); countFilter.id = Not(In(executingWorkflowIds)); - const resultsQuery = await Db.collections - .Execution!.createQueryBuilder('execution') - .select([ - 'execution.id', - 'execution.finished', - 'execution.mode', - 'execution.retryOf', - 'execution.retrySuccessId', - 'execution.waitTill', - 'execution.startedAt', - 'execution.stoppedAt', - 'execution.workflowData', - ]) - .orderBy('execution.id', 'DESC') - .take(limit); + const sharedWorkflowIds = await getSharedWorkflowIds(req.user); - Object.keys(filter).forEach((filterField) => { - if (filterField === 'waitTill') { - resultsQuery.andWhere(`execution.${filterField} is not null`); - } else if (filterField === 'finished' && filter[filterField] === false) { - resultsQuery.andWhere(`execution.${filterField} = :${filterField}`, { - [filterField]: filter[filterField], - }); - resultsQuery.andWhere(`execution.waitTill is null`); + const findOptions: FindManyOptions = { + select: [ + 'id', + 'finished', + 'mode', + 'retryOf', + 'retrySuccessId', + 'waitTill', + 'startedAt', + 'stoppedAt', + 'workflowData', + ], + where: { workflowId: In(sharedWorkflowIds) }, + order: { id: 'DESC' }, + take: limit, + }; + + Object.entries(filter).forEach(([key, value]) => { + let filterToAdd = {}; + + if (key === 'waitTill') { + filterToAdd = { waitTill: !IsNull() }; + } else if (key === 'finished' && value === false) { + filterToAdd = { finished: false, waitTill: IsNull() }; } else { - resultsQuery.andWhere(`execution.${filterField} = :${filterField}`, { - [filterField]: filter[filterField], - }); + filterToAdd = { [key]: value }; } + + Object.assign(findOptions.where, filterToAdd); }); + + const rangeQuery: string[] = []; + const rangeQueryParams: { + lastId?: string; + firstId?: string; + executingWorkflowIds?: string[]; + } = {}; + if (req.query.lastId) { - resultsQuery.andWhere(`execution.id < :lastId`, { lastId: req.query.lastId }); + rangeQuery.push('id < :lastId'); + rangeQueryParams.lastId = req.query.lastId; } + if (req.query.firstId) { - resultsQuery.andWhere(`execution.id > :firstId`, { firstId: req.query.firstId }); + rangeQuery.push('id > :firstId'); + rangeQueryParams.firstId = req.query.firstId; } + if (executingWorkflowIds.length > 0) { - resultsQuery.andWhere(`execution.id NOT IN (:...ids)`, { ids: executingWorkflowIds }); + rangeQuery.push(`id NOT IN (:...executingWorkflowIds)`); + rangeQueryParams.executingWorkflowIds = executingWorkflowIds; } - const resultsPromise = resultsQuery.getMany(); - - const countPromise = getExecutionsCount(countFilter); - - const results: IExecutionFlattedDb[] = await resultsPromise; - const countedObjects = await countPromise; - - const returnResults: IExecutionsSummary[] = []; - - for (const result of results) { - returnResults.push({ - id: result.id.toString(), - finished: result.finished, - mode: result.mode, - retryOf: result.retryOf ? result.retryOf.toString() : undefined, - retrySuccessId: result.retrySuccessId ? result.retrySuccessId.toString() : undefined, - waitTill: result.waitTill as Date | undefined, - startedAt: result.startedAt, - stoppedAt: result.stoppedAt, - workflowId: result.workflowData.id ? result.workflowData.id.toString() : '', - workflowName: result.workflowData.name, + if (rangeQuery.length) { + Object.assign(findOptions.where, { + id: Raw(() => rangeQuery.join(' and '), rangeQueryParams), }); } + const executions = await Db.collections.Execution!.find(findOptions); + + const { count, estimated } = await getExecutionsCount(countFilter, req.user); + + const formattedExecutions = executions.map((execution) => { + return { + id: execution.id.toString(), + finished: execution.finished, + mode: execution.mode, + retryOf: execution.retryOf?.toString(), + retrySuccessId: execution?.retrySuccessId?.toString(), + waitTill: execution.waitTill as Date | undefined, + startedAt: execution.startedAt, + stoppedAt: execution.stoppedAt, + workflowId: execution.workflowData?.id?.toString() ?? '', + workflowName: execution.workflowData.name, + }; + }); + return { - count: countedObjects.count, - results: returnResults, - estimated: countedObjects.estimate, + count, + results: formattedExecutions, + estimated, }; }, ), @@ -2294,22 +2343,43 @@ class App { `/${this.restEndpoint}/executions/:id`, ResponseHelper.send( async ( - req: express.Request, - res: express.Response, + req: ExecutionRequest.Get, ): Promise => { - const result = await Db.collections.Execution!.findOne(req.params.id); + const { id: executionId } = req.params; - if (result === undefined) { + const sharedWorkflowIds = await getSharedWorkflowIds(req.user); + + if (!sharedWorkflowIds.length) return undefined; + + const execution = await Db.collections.Execution!.findOne({ + where: { + id: executionId, + workflowId: In(sharedWorkflowIds), + }, + }); + + if (!execution) { + LoggerProxy.info( + 'Attempt to read execution was blocked due to insufficient permissions', + { + userId: req.user.id, + executionId, + }, + ); return undefined; } if (req.query.unflattedResponse === 'true') { - const fullExecutionData = ResponseHelper.unflattenExecutionData(result); - return fullExecutionData; + return ResponseHelper.unflattenExecutionData(execution); } - // Convert to response format in which the id is a string - (result as IExecutionFlatted as IExecutionFlattedResponse).id = result.id.toString(); - return result as IExecutionFlatted as IExecutionFlattedResponse; + + const { id, ...rest } = execution; + + // @ts-ignore + return { + id: id.toString(), + ...rest, + }; }, ), ); @@ -2317,22 +2387,39 @@ class App { // Retries a failed execution this.app.post( `/${this.restEndpoint}/executions/:id/retry`, - ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - // Get the data to execute - const fullExecutionDataFlatted = await Db.collections.Execution!.findOne(req.params.id); + ResponseHelper.send(async (req: ExecutionRequest.Retry): Promise => { + const { id: executionId } = req.params; - if (fullExecutionDataFlatted === undefined) { + const sharedWorkflowIds = await getSharedWorkflowIds(req.user); + + if (!sharedWorkflowIds.length) return false; + + const execution = await Db.collections.Execution!.findOne({ + where: { + id: executionId, + workflowId: In(sharedWorkflowIds), + }, + }); + + if (!execution) { + LoggerProxy.info( + 'Attempt to retry an execution was blocked due to insufficient permissions', + { + userId: req.user.id, + executionId, + }, + ); throw new ResponseHelper.ResponseError( - `The execution with the id "${req.params.id}" does not exist.`, + `The execution with the ID "${executionId}" does not exist.`, 404, 404, ); } - const fullExecutionData = ResponseHelper.unflattenExecutionData(fullExecutionDataFlatted); + const fullExecutionData = ResponseHelper.unflattenExecutionData(execution); if (fullExecutionData.finished) { - throw new Error('The execution did succeed and can so not be retried.'); + throw new Error('The execution succeeded, so it cannot be retried.'); } const executionMode = 'retry'; @@ -2345,6 +2432,7 @@ class App { executionData: fullExecutionData.data, retryOf: req.params.id, workflowData: fullExecutionData.workflowData, + userId: req.user.id, }; const { lastNodeExecuted } = data.executionData!.resultData; @@ -2364,7 +2452,7 @@ class App { } } - if (req.body.loadWorkflow === true) { + if (req.body.loadWorkflow) { // Loads the currently saved workflow to execute instead of the // one saved at the time of the execution. const workflowId = fullExecutionData.workflowData.id; @@ -2396,6 +2484,11 @@ class App { // Find the data of the last executed node in the new workflow const node = workflowInstance.getNode(stack.node.name); if (node === null) { + LoggerProxy.error('Failed to retry an execution because a node could not be found', { + userId: req.user.id, + executionId, + nodeName: stack.node.name, + }); throw new Error( `Could not find the node "${stack.node.name}" in workflow. It probably got deleted or renamed. Without it the workflow can sadly not be retried.`, ); @@ -2407,13 +2500,13 @@ class App { } const workflowRunner = new WorkflowRunner(); - const executionId = await workflowRunner.run(data); + const retriedExecutionId = await workflowRunner.run(data); const executionData = await this.activeExecutionsInstance.getPostExecutePromise( - executionId, + retriedExecutionId, ); - if (executionData === undefined) { + if (!executionData) { throw new Error('The retry did not start for an unknown reason.'); } @@ -2426,38 +2519,72 @@ class App { // with the query data getting to long this.app.post( `/${this.restEndpoint}/executions/delete`, - ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - const deleteData = req.body as IExecutionDeleteFilter; + ResponseHelper.send(async (req: ExecutionRequest.Delete): Promise => { + const { deleteBefore, ids, filters: requestFilters } = req.body; - if (deleteData.deleteBefore !== undefined) { - const filters = { - startedAt: LessThanOrEqual(deleteData.deleteBefore), + if (!deleteBefore && !ids) { + throw new Error('Either "deleteBefore" or "ids" must be present in the request body'); + } + + const sharedWorkflowIds = await getSharedWorkflowIds(req.user); + const binaryDataManager = BinaryDataManager.getInstance(); + + // delete executions by date, if user may access the underyling worfklows + + if (deleteBefore) { + const filters: IDataObject = { + startedAt: LessThanOrEqual(deleteBefore), }; - if (deleteData.filters !== undefined) { - Object.assign(filters, deleteData.filters); + if (filters) { + Object.assign(filters, requestFilters); } - const execs = await Db.collections.Execution!.find({ ...filters, select: ['id'] }); + const executions = await Db.collections.Execution!.find({ + where: { + workflowId: In(sharedWorkflowIds), + ...filters, + }, + }); + + if (!executions.length) return; + + const idsToDelete = executions.map(({ id }) => id.toString()); await Promise.all( - execs.map(async (item) => - BinaryDataManager.getInstance().deleteBinaryDataByExecutionId(item.id.toString()), - ), + idsToDelete.map(async (id) => binaryDataManager.deleteBinaryDataByExecutionId(id)), ); - await Db.collections.Execution!.delete(filters); - } else if (deleteData.ids !== undefined) { + await Db.collections.Execution!.delete({ id: In(idsToDelete) }); + + return; + } + + // delete executions by IDs, if user may access the underyling worfklows + + if (ids) { + const executions = await Db.collections.Execution!.find({ + where: { + id: In(ids), + workflowId: In(sharedWorkflowIds), + }, + }); + + if (!executions.length) { + LoggerProxy.error('Failed to delete an execution due to insufficient permissions', { + userId: req.user.id, + executionIds: ids, + }); + return; + } + + const idsToDelete = executions.map(({ id }) => id.toString()); + await Promise.all( - deleteData.ids.map(async (id) => - BinaryDataManager.getInstance().deleteBinaryDataByExecutionId(id), - ), + idsToDelete.map(async (id) => binaryDataManager.deleteBinaryDataByExecutionId(id)), ); - // Deletes all executions with the given ids - await Db.collections.Execution!.delete(deleteData.ids); - } else { - throw new Error('Required body-data "ids" or "deleteBefore" is missing!'); + await Db.collections.Execution!.delete(idsToDelete); } }), ); @@ -2470,7 +2597,7 @@ class App { this.app.get( `/${this.restEndpoint}/executions-current`, ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { + async (req: ExecutionRequest.GetAllCurrent): Promise => { if (config.get('executions.mode') === 'queue') { const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']); @@ -2485,56 +2612,62 @@ class App { const currentlyRunningExecutionIds = currentlyRunningQueueIds.concat(manualExecutionIds); - if (currentlyRunningExecutionIds.length === 0) { - return []; - } + if (!currentlyRunningExecutionIds.length) return []; - const resultsQuery = await Db.collections - .Execution!.createQueryBuilder('execution') - .select([ - 'execution.id', - 'execution.workflowId', - 'execution.mode', - 'execution.retryOf', - 'execution.startedAt', - ]) - .orderBy('execution.id', 'DESC') - .andWhere(`execution.id IN (:...ids)`, { ids: currentlyRunningExecutionIds }); + const findOptions: FindManyOptions = { + select: ['id', 'workflowId', 'mode', 'retryOf', 'startedAt'], + order: { id: 'DESC' }, + where: { + id: In(currentlyRunningExecutionIds), + }, + }; + + const sharedWorkflowIds = await getSharedWorkflowIds(req.user); + + if (!sharedWorkflowIds.length) return []; if (req.query.filter) { - const filter = JSON.parse(req.query.filter as string); - if (filter.workflowId !== undefined) { - resultsQuery.andWhere('execution.workflowId = :workflowId', { - workflowId: filter.workflowId, - }); + const { workflowId } = JSON.parse(req.query.filter); + if (workflowId && sharedWorkflowIds.includes(workflowId)) { + Object.assign(findOptions.where, { workflowId }); } + } else { + Object.assign(findOptions.where, { workflowId: In(sharedWorkflowIds) }); } - const results = await resultsQuery.getMany(); + const executions = await Db.collections.Execution!.find(findOptions); - return results.map((result) => { + if (!executions.length) return []; + + return executions.map((execution) => { return { - id: result.id, - workflowId: result.workflowId, - mode: result.mode, - retryOf: result.retryOf !== null ? result.retryOf : undefined, - startedAt: new Date(result.startedAt), + id: execution.id, + workflowId: execution.workflowId, + mode: execution.mode, + retryOf: execution.retryOf !== null ? execution.retryOf : undefined, + startedAt: new Date(execution.startedAt), } as IExecutionsSummary; }); } + const executingWorkflows = this.activeExecutionsInstance.getActiveExecutions(); const returnData: IExecutionsSummary[] = []; - let filter: any = {}; - if (req.query.filter) { - filter = JSON.parse(req.query.filter as string); - } + const filter = req.query.filter ? JSON.parse(req.query.filter) : {}; + + const sharedWorkflowIds = await getSharedWorkflowIds(req.user).then((ids) => + ids.map((id) => id.toString()), + ); for (const data of executingWorkflows) { - if (filter.workflowId !== undefined && filter.workflowId !== data.workflowId) { + if ( + (filter.workflowId !== undefined && filter.workflowId !== data.workflowId) || + !sharedWorkflowIds.includes(data.workflowId) + ) { continue; } + returnData.push({ id: data.id.toString(), workflowId: data.workflowId === undefined ? '' : data.workflowId.toString(), @@ -2543,6 +2676,7 @@ class App { startedAt: new Date(data.startedAt), }); } + returnData.sort((a, b) => parseInt(b.id, 10) - parseInt(a.id, 10)); return returnData; @@ -2553,85 +2687,100 @@ class App { // Forces the execution to stop this.app.post( `/${this.restEndpoint}/executions-current/:id/stop`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - if (config.get('executions.mode') === 'queue') { - // Manual executions should still be stoppable, so - // try notifying the `activeExecutions` to stop it. - const result = await this.activeExecutionsInstance.stopExecution(req.params.id); + ResponseHelper.send(async (req: ExecutionRequest.Stop): Promise => { + const { id: executionId } = req.params; - if (result === undefined) { - // If active execution could not be found check if it is a waiting one - try { - return await this.waitTracker.stopExecution(req.params.id); - } catch (error) { - // Ignore, if it errors as then it is probably a currently running - // execution - } - } else { - return { - mode: result.mode, - startedAt: new Date(result.startedAt), - stoppedAt: result.stoppedAt ? new Date(result.stoppedAt) : undefined, - finished: result.finished, - } as IExecutionsStopData; - } + const sharedWorkflowIds = await getSharedWorkflowIds(req.user); - const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']); + if (!sharedWorkflowIds.length) { + throw new ResponseHelper.ResponseError('Execution not found', undefined, 404); + } - const job = currentJobs.find( - (job) => job.data.executionId.toString() === req.params.id, - ); + const execution = await Db.collections.Execution!.findOne({ + where: { + id: executionId, + workflowId: In(sharedWorkflowIds), + }, + }); - if (!job) { - throw new Error(`Could not stop "${req.params.id}" as it is no longer in queue.`); - } else { - await Queue.getInstance().stopJob(job); - } + if (!execution) { + throw new ResponseHelper.ResponseError('Execution not found', undefined, 404); + } - const executionDb = (await Db.collections.Execution?.findOne( - req.params.id, - )) as IExecutionFlattedDb; - const fullExecutionData = ResponseHelper.unflattenExecutionData(executionDb); + if (config.get('executions.mode') === 'queue') { + // Manual executions should still be stoppable, so + // try notifying the `activeExecutions` to stop it. + const result = await this.activeExecutionsInstance.stopExecution(req.params.id); - const returnData: IExecutionsStopData = { - mode: fullExecutionData.mode, - startedAt: new Date(fullExecutionData.startedAt), - stoppedAt: fullExecutionData.stoppedAt - ? new Date(fullExecutionData.stoppedAt) - : undefined, - finished: fullExecutionData.finished, - }; - - return returnData; - } - const executionId = req.params.id; - - // Stopt he execution and wait till it is done and we got the data - const result = await this.activeExecutionsInstance.stopExecution(executionId); - - let returnData: IExecutionsStopData; if (result === undefined) { // If active execution could not be found check if it is a waiting one - returnData = await this.waitTracker.stopExecution(executionId); + try { + return await this.waitTracker.stopExecution(req.params.id); + } catch (error) { + // Ignore, if it errors as then it is probably a currently running + // execution + } } else { - returnData = { + return { mode: result.mode, startedAt: new Date(result.startedAt), stoppedAt: result.stoppedAt ? new Date(result.stoppedAt) : undefined, finished: result.finished, - }; + } as IExecutionsStopData; } + const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']); + + const job = currentJobs.find((job) => job.data.executionId.toString() === req.params.id); + + if (!job) { + throw new Error(`Could not stop "${req.params.id}" as it is no longer in queue.`); + } else { + await Queue.getInstance().stopJob(job); + } + + const executionDb = (await Db.collections.Execution?.findOne( + req.params.id, + )) as IExecutionFlattedDb; + const fullExecutionData = ResponseHelper.unflattenExecutionData(executionDb); + + const returnData: IExecutionsStopData = { + mode: fullExecutionData.mode, + startedAt: new Date(fullExecutionData.startedAt), + stoppedAt: fullExecutionData.stoppedAt + ? new Date(fullExecutionData.stoppedAt) + : undefined, + finished: fullExecutionData.finished, + }; + return returnData; - }, - ), + } + + // Stop the execution and wait till it is done and we got the data + const result = await this.activeExecutionsInstance.stopExecution(executionId); + + let returnData: IExecutionsStopData; + if (result === undefined) { + // If active execution could not be found check if it is a waiting one + returnData = await this.waitTracker.stopExecution(executionId); + } else { + returnData = { + mode: result.mode, + startedAt: new Date(result.startedAt), + stoppedAt: result.stoppedAt ? new Date(result.stoppedAt) : undefined, + finished: result.finished, + }; + } + + return returnData; + }), ); // Removes a test webhook this.app.delete( `/${this.restEndpoint}/test-webhook/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + // TODO UM: check if this needs validation with user management. const workflowId = req.params.id; return this.testWebhooks.cancelTestWebhook(workflowId); }), @@ -2657,6 +2806,7 @@ class App { this.app.get( `/${this.restEndpoint}/data/:path`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + // TODO UM: check if this needs permission check for UM const dataPath = req.params.path; return BinaryDataManager.getInstance() .retrieveBinaryDataByIdentifier(dataPath) @@ -2670,41 +2820,16 @@ class App { // Settings // ---------------------------------------- - // Returns the settings which are needed in the UI + // Returns the current settings for the UI this.app.get( `/${this.restEndpoint}/settings`, ResponseHelper.send( async (req: express.Request, res: express.Response): Promise => { - return this.frontendSettings; + return this.getSettingsForFrontend(); }, ), ); - // ---------------------------------------- - // User Survey - // ---------------------------------------- - - // Process personalization survey responses - this.app.post( - `/${this.restEndpoint}/user-survey`, - async (req: express.Request, res: express.Response) => { - if (!this.frontendSettings.personalizationSurvey.shouldShow) { - ResponseHelper.sendErrorResponse( - res, - new ResponseHelper.ResponseError('User survey already submitted', undefined, 400), - false, - ); - } - - const answers = req.body as IPersonalizationSurveyAnswers; - await PersonalizationSurvey.writeSurveyToDisk(answers); - this.frontendSettings.personalizationSurvey.shouldShow = false; - this.frontendSettings.personalizationSurvey.answers = answers; - ResponseHelper.sendSuccessResponse(res, undefined, true, 200); - void InternalHooksManager.getInstance().onPersonalizationSurveySubmitted(answers); - }, - ); - // ---------------------------------------- // Webhooks // ---------------------------------------- @@ -2908,6 +3033,10 @@ export async function start(): Promise { }, deploymentType: config.get('deployment.type'), binaryDataMode: binarDataConfig.mode, + n8n_multi_user_allowed: + config.get('userManagement.disabled') === false || + config.get('userManagement.isInstanceOwnerSetUp') === true, + smtp_set_up: config.get('userManagement.emails.mode') === 'smtp', }; void Db.collections @@ -2932,15 +3061,24 @@ export async function start(): Promise { async function getExecutionsCount( countFilter: IDataObject, -): Promise<{ count: number; estimate: boolean }> { + user: User, +): Promise<{ count: number; estimated: boolean }> { const dbType = (await GenericHelpers.getConfigValue('database.type')) as DatabaseType; const filteredFields = Object.keys(countFilter).filter((field) => field !== 'id'); - // Do regular count for other databases than pgsql and - // if we are filtering based on workflowId or finished fields. - if (dbType !== 'postgresdb' || filteredFields.length > 0) { - const count = await Db.collections.Execution!.count(countFilter); - return { count, estimate: false }; + // For databases other than Postgres, do a regular count + // when filtering based on `workflowId` or `finished` fields. + if (dbType !== 'postgresdb' || filteredFields.length > 0 || user.globalRole.name !== 'owner') { + const sharedWorkflowIds = await getSharedWorkflowIds(user); + + const count = await Db.collections.Execution!.count({ + where: { + workflowId: In(sharedWorkflowIds), + ...countFilter, + }, + }); + + return { count, estimated: false }; } try { @@ -2953,15 +3091,22 @@ async function getExecutionsCount( const estimate = parseInt(rows[0].n_live_tup, 10); // If over 100k, return just an estimate. - if (estimate > 100000) { + if (estimate > 100_000) { // if less than 100k, we get the real count as even a full // table scan should not take so long. - return { count: estimate, estimate: true }; + return { count: estimate, estimated: true }; } - } catch (err) { - LoggerProxy.warn(`Unable to get executions count from postgres: ${err}`); + } catch (error) { + LoggerProxy.warn(`Failed to get executions count from Postgres: ${error}`); } - const count = await Db.collections.Execution!.count(countFilter); - return { count, estimate: false }; + const sharedWorkflowIds = await getSharedWorkflowIds(user); + + const count = await Db.collections.Execution!.count({ + where: { + workflowId: In(sharedWorkflowIds), + }, + }); + + return { count, estimated: false }; } diff --git a/packages/cli/src/TagHelpers.ts b/packages/cli/src/TagHelpers.ts index dc54fb2469..31e2a063f9 100644 --- a/packages/cli/src/TagHelpers.ts +++ b/packages/cli/src/TagHelpers.ts @@ -2,9 +2,6 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable import/no-cycle */ import { getConnection } from 'typeorm'; -import { validate } from 'class-validator'; - -import { ResponseHelper } from '.'; import { TagEntity } from './databases/entities/TagEntity'; @@ -15,43 +12,18 @@ import { ITagWithCountDb } from './Interfaces'; // ---------------------------------- /** - * Sort a `TagEntity[]` by the order of the tag IDs in the incoming request. + * Sort tags based on the order of the tag IDs in the request. */ -export function sortByRequestOrder(tagsDb: TagEntity[], tagIds: string[]) { - const tagMap = tagsDb.reduce((acc, tag) => { - // @ts-ignore - tag.id = tag.id.toString(); - acc[tag.id] = tag; +export function sortByRequestOrder( + tags: TagEntity[], + { requestOrder }: { requestOrder: string[] }, +) { + const tagMap = tags.reduce>((acc, tag) => { + acc[tag.id.toString()] = tag; return acc; - }, {} as { [key: string]: TagEntity }); + }, {}); - return tagIds.map((tagId) => tagMap[tagId]); -} - -// ---------------------------------- -// validators -// ---------------------------------- - -/** - * Validate a new tag based on `class-validator` constraints. - */ -export async function validateTag(newTag: TagEntity) { - const errors = await validate(newTag); - - if (errors.length) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const validationErrorMessage = Object.values(errors[0].constraints!)[0]; - throw new ResponseHelper.ResponseError(validationErrorMessage, undefined, 400); - } -} - -export function throwDuplicateEntryError(error: Error) { - const errorMessage = error.message.toLowerCase(); - if (errorMessage.includes('unique') || errorMessage.includes('duplicate')) { - throw new ResponseHelper.ResponseError('Tag name already exists', undefined, 400); - } - - throw new ResponseHelper.ResponseError(errorMessage, undefined, 400); + return requestOrder.map((tagId) => tagMap[tagId]); } // ---------------------------------- diff --git a/packages/cli/src/UserManagement/Interfaces.ts b/packages/cli/src/UserManagement/Interfaces.ts new file mode 100644 index 0000000000..4f64479b92 --- /dev/null +++ b/packages/cli/src/UserManagement/Interfaces.ts @@ -0,0 +1,40 @@ +/* eslint-disable import/no-cycle */ +import { Application } from 'express'; +import { JwtFromRequestFunction } from 'passport-jwt'; +import type { IExternalHooksClass, IPersonalizationSurveyAnswers } from '../Interfaces'; +import { ActiveWorkflowRunner } from '..'; + +export interface JwtToken { + token: string; + expiresIn: number; +} + +export interface JwtOptions { + secretOrKey: string; + jwtFromRequest: JwtFromRequestFunction; +} + +export interface JwtPayload { + id: string; + email: string | null; + password: string | null; +} + +export interface PublicUser { + id: string; + email?: string; + firstName?: string; + lastName?: string; + personalizationAnswers?: IPersonalizationSurveyAnswers | null; + password?: string; + passwordResetToken?: string; + isPending: boolean; +} + +export interface N8nApp { + app: Application; + restEndpoint: string; + externalHooks: IExternalHooksClass; + defaultCredentialsName: string; + activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner; +} diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts new file mode 100644 index 0000000000..332a0efb77 --- /dev/null +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -0,0 +1,218 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable import/no-cycle */ +import { Workflow } from 'n8n-workflow'; +import { In, IsNull, Not } from 'typeorm'; +import express = require('express'); +import { PublicUser } from './Interfaces'; +import { Db, GenericHelpers, ResponseHelper } from '..'; +import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, User } from '../databases/entities/User'; +import { Role } from '../databases/entities/Role'; +import { AuthenticatedRequest } from '../requests'; +import config = require('../../config'); +import { getWebhookBaseUrl } from '../WebhookHelpers'; + +export async function getWorkflowOwner(workflowId: string | number): Promise { + const sharedWorkflow = await Db.collections.SharedWorkflow!.findOneOrFail({ + where: { workflow: { id: workflowId } }, + relations: ['user', 'user.globalRole'], + }); + + return sharedWorkflow.user; +} + +export function isEmailSetUp(): boolean { + const smtp = config.get('userManagement.emails.mode') === 'smtp'; + const host = !!config.get('userManagement.emails.smtp.host'); + const user = !!config.get('userManagement.emails.smtp.auth.user'); + const pass = !!config.get('userManagement.emails.smtp.auth.pass'); + + return smtp && host && user && pass; +} + +async function getInstanceOwnerRole(): Promise { + const ownerRole = await Db.collections.Role!.findOneOrFail({ + where: { + name: 'owner', + scope: 'global', + }, + }); + return ownerRole; +} + +export async function getInstanceOwner(): Promise { + const ownerRole = await getInstanceOwnerRole(); + + const owner = await Db.collections.User!.findOneOrFail({ + relations: ['globalRole'], + where: { + globalRole: ownerRole, + }, + }); + return owner; +} + +/** + * Return the n8n instance base URL without trailing slash. + */ +export function getInstanceBaseUrl(): string { + const n8nBaseUrl = config.get('editorBaseUrl') || getWebhookBaseUrl(); + + return n8nBaseUrl.endsWith('/') ? n8nBaseUrl.slice(0, n8nBaseUrl.length - 1) : n8nBaseUrl; +} + +export async function isInstanceOwnerSetup(): Promise { + const users = await Db.collections.User!.find({ email: Not(IsNull()) }); + return users.length !== 0; +} + +// TODO: Enforce at model level +export function validatePassword(password?: string): string { + if (!password) { + throw new ResponseHelper.ResponseError('Password is mandatory', undefined, 400); + } + + const hasInvalidLength = + password.length < MIN_PASSWORD_LENGTH || password.length > MAX_PASSWORD_LENGTH; + + const hasNoNumber = !/\d/.test(password); + + const hasNoUppercase = !/[A-Z]/.test(password); + + if (hasInvalidLength || hasNoNumber || hasNoUppercase) { + const message: string[] = []; + + if (hasInvalidLength) { + message.push( + `Password must be ${MIN_PASSWORD_LENGTH} to ${MAX_PASSWORD_LENGTH} characters long.`, + ); + } + + if (hasNoNumber) { + message.push('Password must contain at least 1 number.'); + } + + if (hasNoUppercase) { + message.push('Password must contain at least 1 uppercase letter.'); + } + + throw new ResponseHelper.ResponseError(message.join(' '), undefined, 400); + } + + return password; +} + +/** + * Remove sensitive properties from the user to return to the client. + */ +export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser { + const { + password, + resetPasswordToken, + resetPasswordTokenExpiration, + createdAt, + updatedAt, + ...sanitizedUser + } = user; + if (withoutKeys) { + withoutKeys.forEach((key) => { + // @ts-ignore + delete sanitizedUser[key]; + }); + } + return sanitizedUser; +} + +export async function getUserById(userId: string): Promise { + const user = await Db.collections.User!.findOneOrFail(userId, { + relations: ['globalRole'], + }); + return user; +} + +export async function checkPermissionsForExecution( + workflow: Workflow, + userId: string, +): Promise { + const credentialIds = new Set(); + const nodeNames = Object.keys(workflow.nodes); + // Iterate over all nodes + nodeNames.forEach((nodeName) => { + const node = workflow.nodes[nodeName]; + // And check if any of the nodes uses credentials. + if (node.credentials) { + const credentialNames = Object.keys(node.credentials); + // For every credential this node uses + credentialNames.forEach((credentialName) => { + const credentialDetail = node.credentials![credentialName]; + // If it does not contain an id, it means it is a very old + // workflow. Nowaways it should not happen anymore. + // Migrations should handle the case where a credential does + // not have an id. + if (!credentialDetail.id) { + throw new Error( + 'Error initializing workflow: credential ID not present. Please open the workflow and save it to fix this error.', + ); + } + credentialIds.add(credentialDetail.id.toString()); + }); + } + }); + + // Now that we obtained all credential IDs used by this workflow, we can + // now check if the owner of this workflow has access to all of them. + + const ids = Array.from(credentialIds); + + if (ids.length === 0) { + // If the workflow does not use any credentials, then we're fine + return true; + } + // If this check happens on top, we may get + // unitialized db errors. + // Db is certainly initialized if workflow uses credentials. + const user = await getUserById(userId); + if (user.globalRole.name === 'owner') { + return true; + } + + // Check for the user's permission to all used credentials + const credentialCount = await Db.collections.SharedCredentials!.count({ + where: { + user: { id: userId }, + credentials: In(ids), + }, + }); + + // Considering the user needs to have access to all credentials + // then both arrays (allowed credentials vs used credentials) + // must be the same length + if (ids.length !== credentialCount) { + throw new Error('One or more of the used credentials are not accessable.'); + } + return true; +} + +/** + * Check if a URL contains an auth-excluded endpoint. + */ +export function isAuthExcluded(url: string, ignoredEndpoints: string[]): boolean { + return !!ignoredEndpoints + .filter(Boolean) // skip empty paths + .find((ignoredEndpoint) => url.includes(ignoredEndpoint)); +} + +/** + * Check if the endpoint is `POST /users/:id`. + */ +export function isPostUsersId(req: express.Request, restEndpoint: string): boolean { + return ( + req.method === 'POST' && + new RegExp(`/${restEndpoint}/users/[\\w\\d-]*`).test(req.url) && + !req.url.includes('reinvite') + ); +} + +export function isAuthenticatedRequest(request: express.Request): request is AuthenticatedRequest { + return request.user !== undefined; +} diff --git a/packages/cli/src/UserManagement/auth/jwt.ts b/packages/cli/src/UserManagement/auth/jwt.ts new file mode 100644 index 0000000000..2dca697801 --- /dev/null +++ b/packages/cli/src/UserManagement/auth/jwt.ts @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable import/no-cycle */ + +import * as jwt from 'jsonwebtoken'; +import { Response } from 'express'; +import { createHash } from 'crypto'; +import { Db } from '../..'; +import { AUTH_COOKIE_NAME } from '../../constants'; +import { JwtToken, JwtPayload } from '../Interfaces'; +import { User } from '../../databases/entities/User'; +import config = require('../../../config'); + +export function issueJWT(user: User): JwtToken { + const { id, email, password } = user; + const expiresIn = 7 * 86400000; // 7 days + + const payload: JwtPayload = { + id, + email, + password: password ?? null, + }; + + if (password) { + payload.password = createHash('sha256') + .update(password.slice(password.length / 2)) + .digest('hex'); + } + + const signedToken = jwt.sign(payload, config.get('userManagement.jwtSecret'), { + expiresIn: expiresIn / 1000 /* in seconds */, + }); + + return { + token: signedToken, + expiresIn, + }; +} + +export async function resolveJwtContent(jwtPayload: JwtPayload): Promise { + const user = await Db.collections.User!.findOne(jwtPayload.id, { + relations: ['globalRole'], + }); + + let passwordHash = null; + if (user?.password) { + passwordHash = createHash('sha256') + .update(user.password.slice(user.password.length / 2)) + .digest('hex'); + } + + if (!user || jwtPayload.password !== passwordHash || user.email !== jwtPayload.email) { + // When owner hasn't been set up, the default user + // won't have email nor password (both equals null) + throw new Error('Invalid token content'); + } + return user; +} + +export async function resolveJwt(token: string): Promise { + const jwtPayload = jwt.verify(token, config.get('userManagement.jwtSecret')) as JwtPayload; + return resolveJwtContent(jwtPayload); +} + +export async function issueCookie(res: Response, user: User): Promise { + const userData = issueJWT(user); + res.cookie(AUTH_COOKIE_NAME, userData.token, { maxAge: userData.expiresIn, httpOnly: true }); +} diff --git a/packages/cli/src/UserManagement/email/Interfaces.ts b/packages/cli/src/UserManagement/email/Interfaces.ts new file mode 100644 index 0000000000..97da3d49ee --- /dev/null +++ b/packages/cli/src/UserManagement/email/Interfaces.ts @@ -0,0 +1,32 @@ +export interface UserManagementMailerImplementation { + sendMail: (mailData: MailData) => Promise; + verifyConnection: () => Promise; +} + +export type InviteEmailData = { + email: string; + firstName?: string; + lastName?: string; + inviteAcceptUrl: string; + domain: string; +}; + +export type PasswordResetData = { + email: string; + firstName?: string; + lastName?: string; + passwordResetUrl: string; + domain: string; +}; + +export type SendEmailResult = { + success: boolean; + error?: Error; +}; + +export type MailData = { + body: string | Buffer; + emailRecipients: string | string[]; + subject: string; + textOnly?: string; +}; diff --git a/packages/cli/src/UserManagement/email/NodeMailer.ts b/packages/cli/src/UserManagement/email/NodeMailer.ts new file mode 100644 index 0000000000..8ea708a2f3 --- /dev/null +++ b/packages/cli/src/UserManagement/email/NodeMailer.ts @@ -0,0 +1,74 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { createTransport, Transporter } from 'nodemailer'; +import { LoggerProxy as Logger } from 'n8n-workflow'; +import config = require('../../../config'); +import { MailData, SendEmailResult, UserManagementMailerImplementation } from './Interfaces'; + +export class NodeMailer implements UserManagementMailerImplementation { + private transport: Transporter; + + constructor() { + this.transport = createTransport({ + host: config.get('userManagement.emails.smtp.host'), + port: config.get('userManagement.emails.smtp.port'), + secure: config.get('userManagement.emails.smtp.secure'), + auth: { + user: config.get('userManagement.emails.smtp.auth.user'), + pass: config.get('userManagement.emails.smtp.auth.pass'), + }, + }); + } + + async verifyConnection(): Promise { + const host = config.get('userManagement.emails.smtp.host') as string; + const user = config.get('userManagement.emails.smtp.auth.user') as string; + const pass = config.get('userManagement.emails.smtp.auth.pass') as string; + + return new Promise((resolve, reject) => { + this.transport.verify((error: Error) => { + if (!error) { + resolve(); + return; + } + + const message = []; + + if (!host) message.push('SMTP host not defined (N8N_SMTP_HOST).'); + if (!user) message.push('SMTP user not defined (N8N_SMTP_USER).'); + if (!pass) message.push('SMTP pass not defined (N8N_SMTP_PASS).'); + + reject(new Error(message.length ? message.join(' ') : error.message)); + }); + }); + } + + async sendMail(mailData: MailData): Promise { + let sender = config.get('userManagement.emails.smtp.sender'); + const user = config.get('userManagement.emails.smtp.auth.user') as string; + + if (!sender && user.includes('@')) { + sender = user; + } + + try { + await this.transport.sendMail({ + from: sender, + to: mailData.emailRecipients, + subject: mailData.subject, + text: mailData.textOnly, + html: mailData.body, + }); + Logger.verbose( + `Email sent successfully to the following recipients: ${mailData.emailRecipients.toString()}`, + ); + } catch (error) { + Logger.error('Failed to send email', { recipients: mailData.emailRecipients, error }); + return { + success: false, + error, + }; + } + + return { success: true }; + } +} diff --git a/packages/cli/src/UserManagement/email/UserManagementMailer.ts b/packages/cli/src/UserManagement/email/UserManagementMailer.ts new file mode 100644 index 0000000000..525f5397d3 --- /dev/null +++ b/packages/cli/src/UserManagement/email/UserManagementMailer.ts @@ -0,0 +1,98 @@ +/* eslint-disable import/no-cycle */ +import { existsSync, readFileSync } from 'fs'; +import { IDataObject } from 'n8n-workflow'; +import { join as pathJoin } from 'path'; +import { GenericHelpers } from '../..'; +import config = require('../../../config'); +import { + InviteEmailData, + PasswordResetData, + SendEmailResult, + UserManagementMailerImplementation, +} from './Interfaces'; +import { NodeMailer } from './NodeMailer'; + +async function getTemplate(configKeyName: string, defaultFilename: string) { + const templateOverride = (await GenericHelpers.getConfigValue( + `userManagement.emails.templates.${configKeyName}`, + )) as string; + + let template; + if (templateOverride && existsSync(templateOverride)) { + template = readFileSync(templateOverride, { + encoding: 'utf-8', + }); + } else { + template = readFileSync(pathJoin(__dirname, `templates/${defaultFilename}`), { + encoding: 'utf-8', + }); + } + return template; +} + +function replaceStrings(template: string, data: IDataObject) { + let output = template; + const keys = Object.keys(data); + keys.forEach((key) => { + const regex = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g'); + output = output.replace(regex, data[key] as string); + }); + return output; +} + +export class UserManagementMailer { + private mailer: UserManagementMailerImplementation | undefined; + + constructor() { + // Other implementations can be used in the future. + if (config.get('userManagement.emails.mode') === 'smtp') { + this.mailer = new NodeMailer(); + } + } + + async verifyConnection(): Promise { + if (!this.mailer) return Promise.reject(); + + return this.mailer.verifyConnection(); + } + + async invite(inviteEmailData: InviteEmailData): Promise { + let template = await getTemplate('invite', 'invite.html'); + template = replaceStrings(template, inviteEmailData); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const result = await this.mailer?.sendMail({ + emailRecipients: inviteEmailData.email, + subject: 'You have been invited to n8n', + body: template, + }); + + // If mailer does not exist it means mail has been disabled. + return result ?? { success: true }; + } + + async passwordReset(passwordResetData: PasswordResetData): Promise { + let template = await getTemplate('passwordReset', 'passwordReset.html'); + template = replaceStrings(template, passwordResetData); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const result = await this.mailer?.sendMail({ + emailRecipients: passwordResetData.email, + subject: 'n8n password reset', + body: template, + }); + + // If mailer does not exist it means mail has been disabled. + return result ?? { success: true }; + } +} + +let mailerInstance: UserManagementMailer | undefined; + +export async function getInstance(): Promise { + if (mailerInstance === undefined) { + mailerInstance = new UserManagementMailer(); + await mailerInstance.verifyConnection(); + } + return mailerInstance; +} diff --git a/packages/cli/src/UserManagement/email/index.ts b/packages/cli/src/UserManagement/email/index.ts new file mode 100644 index 0000000000..1d49343ff7 --- /dev/null +++ b/packages/cli/src/UserManagement/email/index.ts @@ -0,0 +1,4 @@ +/* eslint-disable import/no-cycle */ +import { getInstance, UserManagementMailer } from './UserManagementMailer'; + +export { getInstance, UserManagementMailer }; diff --git a/packages/cli/src/UserManagement/email/templates/instanceSetup.html b/packages/cli/src/UserManagement/email/templates/instanceSetup.html new file mode 100644 index 0000000000..0a6c785912 --- /dev/null +++ b/packages/cli/src/UserManagement/email/templates/instanceSetup.html @@ -0,0 +1,5 @@ +

Hi there!

+

Welcome to n8n, {{firstName}} {{lastName}}

+

Your instance is set up!

+

Use your email to login: {{email}} and the chosen password.

+

Have fun automating!

diff --git a/packages/cli/src/UserManagement/email/templates/invite.html b/packages/cli/src/UserManagement/email/templates/invite.html new file mode 100644 index 0000000000..178017d19d --- /dev/null +++ b/packages/cli/src/UserManagement/email/templates/invite.html @@ -0,0 +1,4 @@ +

Hi there,

+

You have been invited to join n8n ({{ domain }}).

+

To accept, click the following link:

+

{{ inviteAcceptUrl }}

diff --git a/packages/cli/src/UserManagement/email/templates/passwordReset.html b/packages/cli/src/UserManagement/email/templates/passwordReset.html new file mode 100644 index 0000000000..161407592e --- /dev/null +++ b/packages/cli/src/UserManagement/email/templates/passwordReset.html @@ -0,0 +1,5 @@ +

Hi {{firstName}},

+

Somebody asked to reset your password on n8n ({{ domain }}).

+

If it was not you, you can safely ignore this email.

+

Click the following link to choose a new password. The link is valid for 2 hours.

+{{ passwordResetUrl }} diff --git a/packages/cli/src/UserManagement/index.ts b/packages/cli/src/UserManagement/index.ts new file mode 100644 index 0000000000..b2d04508fe --- /dev/null +++ b/packages/cli/src/UserManagement/index.ts @@ -0,0 +1,4 @@ +/* eslint-disable import/no-cycle */ +import { addRoutes } from './routes'; + +export const userManagementRouter = { addRoutes }; diff --git a/packages/cli/src/UserManagement/routes/auth.ts b/packages/cli/src/UserManagement/routes/auth.ts new file mode 100644 index 0000000000..a91e46497a --- /dev/null +++ b/packages/cli/src/UserManagement/routes/auth.ts @@ -0,0 +1,119 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable import/no-cycle */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { Request, Response } from 'express'; +import { compare } from 'bcryptjs'; +import { IDataObject } from 'n8n-workflow'; +import { Db, ResponseHelper } from '../..'; +import { AUTH_COOKIE_NAME } from '../../constants'; +import { issueCookie, resolveJwt } from '../auth/jwt'; +import { N8nApp, PublicUser } from '../Interfaces'; +import { isInstanceOwnerSetup, sanitizeUser } from '../UserManagementHelper'; +import { User } from '../../databases/entities/User'; +import type { LoginRequest } from '../../requests'; + +export function authenticationMethods(this: N8nApp): void { + /** + * Log in a user. + * + * Authless endpoint. + */ + this.app.post( + `/${this.restEndpoint}/login`, + ResponseHelper.send(async (req: LoginRequest, res: Response): Promise => { + if (!req.body.email) { + throw new Error('Email is required to log in'); + } + + if (!req.body.password) { + throw new Error('Password is required to log in'); + } + + let user; + try { + user = await Db.collections.User!.findOne( + { + email: req.body.email, + }, + { + relations: ['globalRole'], + }, + ); + } catch (error) { + throw new Error('Unable to access database.'); + } + if (!user || !user.password || !(await compare(req.body.password, user.password))) { + // password is empty until user signs up + const error = new Error('Wrong username or password. Do you have caps lock on?'); + // @ts-ignore + error.httpStatusCode = 401; + throw error; + } + + await issueCookie(res, user); + + return sanitizeUser(user); + }), + ); + + /** + * Manually check the `n8n-auth` cookie. + */ + this.app.get( + `/${this.restEndpoint}/login`, + ResponseHelper.send(async (req: Request, res: Response): Promise => { + // Manually check the existing cookie. + const cookieContents = req.cookies?.[AUTH_COOKIE_NAME] as string | undefined; + + let user: User; + if (cookieContents) { + // If logged in, return user + try { + user = await resolveJwt(cookieContents); + return sanitizeUser(user); + } catch (error) { + res.clearCookie(AUTH_COOKIE_NAME); + } + } + + if (await isInstanceOwnerSetup()) { + const error = new Error('Not logged in'); + // @ts-ignore + error.httpStatusCode = 401; + throw error; + } + + try { + user = await Db.collections.User!.findOneOrFail({ relations: ['globalRole'] }); + } catch (error) { + throw new Error( + 'No users found in database - did you wipe the users table? Create at least one user.', + ); + } + + if (user.email || user.password) { + throw new Error('Invalid database state - user has password set.'); + } + + await issueCookie(res, user); + + return sanitizeUser(user); + }), + ); + + /** + * Log out a user. + * + * Authless endpoint. + */ + this.app.post( + `/${this.restEndpoint}/logout`, + ResponseHelper.send(async (_, res: Response): Promise => { + res.clearCookie(AUTH_COOKIE_NAME); + return { + loggedOut: true, + }; + }), + ); +} diff --git a/packages/cli/src/UserManagement/routes/index.ts b/packages/cli/src/UserManagement/routes/index.ts new file mode 100644 index 0000000000..24fe8a26e0 --- /dev/null +++ b/packages/cli/src/UserManagement/routes/index.ts @@ -0,0 +1,126 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable import/no-cycle */ +import cookieParser = require('cookie-parser'); +import * as passport from 'passport'; +import { Strategy } from 'passport-jwt'; +import { NextFunction, Request, Response } from 'express'; +import * as jwt from 'jsonwebtoken'; +import { LoggerProxy as Logger } from 'n8n-workflow'; + +import { JwtPayload, N8nApp } from '../Interfaces'; +import { authenticationMethods } from './auth'; +import config = require('../../../config'); +import { AUTH_COOKIE_NAME } from '../../constants'; +import { issueCookie, resolveJwtContent } from '../auth/jwt'; +import { meNamespace } from './me'; +import { usersNamespace } from './users'; +import { passwordResetNamespace } from './passwordReset'; +import { AuthenticatedRequest } from '../../requests'; +import { ownerNamespace } from './owner'; +import { isAuthExcluded, isPostUsersId, isAuthenticatedRequest } from '../UserManagementHelper'; + +export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint: string): void { + // needed for testing; not adding overhead since it directly returns if req.cookies exists + this.app.use(cookieParser()); + + const options = { + jwtFromRequest: (req: Request) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return (req.cookies?.[AUTH_COOKIE_NAME] as string | undefined) ?? null; + }, + secretOrKey: config.get('userManagement.jwtSecret') as string, + }; + + passport.use( + new Strategy(options, async function validateCookieContents(jwtPayload: JwtPayload, done) { + try { + const user = await resolveJwtContent(jwtPayload); + return done(null, user); + } catch (error) { + Logger.debug('Failed to extract user from JWT payload', { jwtPayload }); + return done(null, false, { message: 'User not found' }); + } + }), + ); + + this.app.use(passport.initialize()); + + this.app.use((req: Request, res: Response, next: NextFunction) => { + if ( + // TODO: refactor me!!! + // skip authentication for preflight requests + req.method === 'OPTIONS' || + req.url === '/index.html' || + req.url === '/favicon.ico' || + req.url.startsWith('/css/') || + req.url.startsWith('/js/') || + req.url.startsWith('/fonts/') || + req.url.includes('.svg') || + req.url.startsWith(`/${restEndpoint}/settings`) || + req.url.includes('login') || + req.url.includes('logout') || + req.url.startsWith(`/${restEndpoint}/resolve-signup-token`) || + isPostUsersId(req, restEndpoint) || + req.url.startsWith(`/${restEndpoint}/forgot-password`) || + req.url.startsWith(`/${restEndpoint}/resolve-password-token`) || + req.url.startsWith(`/${restEndpoint}/change-password`) || + isAuthExcluded(req.url, ignoredEndpoints) + ) { + return next(); + } + + return passport.authenticate('jwt', { session: false })(req, res, next); + }); + + this.app.use((req: Request | AuthenticatedRequest, res: Response, next: NextFunction) => { + // req.user is empty for public routes, so just proceed + // owner can do anything, so proceed as well + if (!req.user || (isAuthenticatedRequest(req) && req.user.globalRole.name === 'owner')) { + next(); + return; + } + // Not owner and user exists. We now protect restricted urls. + const postRestrictedUrls = [`/${this.restEndpoint}/users`, `/${this.restEndpoint}/owner`]; + const getRestrictedUrls = [`/${this.restEndpoint}/users`]; + const trimmedUrl = req.url.endsWith('/') ? req.url.slice(0, -1) : req.url; + if ( + (req.method === 'POST' && postRestrictedUrls.includes(trimmedUrl)) || + (req.method === 'GET' && getRestrictedUrls.includes(trimmedUrl)) || + (req.method === 'DELETE' && + new RegExp(`/${restEndpoint}/users/[^/]+`, 'gm').test(trimmedUrl)) || + (req.method === 'POST' && + new RegExp(`/${restEndpoint}/users/[^/]+/reinvite`, 'gm').test(trimmedUrl)) || + new RegExp(`/${restEndpoint}/owner/[^/]+`, 'gm').test(trimmedUrl) + ) { + Logger.verbose('User attempted to access endpoint without authorization', { + endpoint: `${req.method} ${trimmedUrl}`, + userId: isAuthenticatedRequest(req) ? req.user.id : 'unknown', + }); + res.status(403).json({ status: 'error', message: 'Unauthorized' }); + return; + } + + next(); + }); + + // middleware to refresh cookie before it expires + this.app.use(async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + const cookieAuth = options.jwtFromRequest(req); + if (cookieAuth && req.user) { + const cookieContents = jwt.decode(cookieAuth) as JwtPayload & { exp: number }; + if (cookieContents.exp * 1000 - Date.now() < 259200000) { + // if cookie expires in < 3 days, renew it. + await issueCookie(res, req.user); + } + } + next(); + }); + + authenticationMethods.apply(this); + ownerNamespace.apply(this); + meNamespace.apply(this); + passwordResetNamespace.apply(this); + usersNamespace.apply(this); +} diff --git a/packages/cli/src/UserManagement/routes/me.ts b/packages/cli/src/UserManagement/routes/me.ts new file mode 100644 index 0000000000..1c4ed9f427 --- /dev/null +++ b/packages/cli/src/UserManagement/routes/me.ts @@ -0,0 +1,154 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable import/no-cycle */ + +import { compare, genSaltSync, hashSync } from 'bcryptjs'; +import express = require('express'); +import validator from 'validator'; +import { LoggerProxy as Logger } from 'n8n-workflow'; + +import { Db, InternalHooksManager, ResponseHelper } from '../..'; +import { issueCookie } from '../auth/jwt'; +import { N8nApp, PublicUser } from '../Interfaces'; +import { validatePassword, sanitizeUser } from '../UserManagementHelper'; +import type { AuthenticatedRequest, MeRequest } from '../../requests'; +import { validateEntity } from '../../GenericHelpers'; +import { User } from '../../databases/entities/User'; + +export function meNamespace(this: N8nApp): void { + /** + * Return the logged-in user. + */ + this.app.get( + `/${this.restEndpoint}/me`, + ResponseHelper.send(async (req: AuthenticatedRequest): Promise => { + return sanitizeUser(req.user); + }), + ); + + /** + * Update the logged-in user's settings, except password. + */ + this.app.patch( + `/${this.restEndpoint}/me`, + ResponseHelper.send( + async (req: MeRequest.Settings, res: express.Response): Promise => { + if (!req.body.email) { + Logger.debug('Request to update user email failed because of missing email in payload', { + userId: req.user.id, + payload: req.body, + }); + throw new ResponseHelper.ResponseError('Email is mandatory', undefined, 400); + } + + if (!validator.isEmail(req.body.email)) { + Logger.debug('Request to update user email failed because of invalid email in payload', { + userId: req.user.id, + invalidEmail: req.body.email, + }); + throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); + } + + const newUser = new User(); + + Object.assign(newUser, req.user, req.body); + + await validateEntity(newUser); + + const user = await Db.collections.User!.save(newUser); + + Logger.info('User updated successfully', { userId: user.id }); + + await issueCookie(res, user); + + const updatedkeys = Object.keys(req.body); + void InternalHooksManager.getInstance().onUserUpdate({ + user_id: req.user.id, + fields_changed: updatedkeys, + }); + + return sanitizeUser(user); + }, + ), + ); + + /** + * Update the logged-in user's password. + */ + this.app.patch( + `/${this.restEndpoint}/me/password`, + ResponseHelper.send(async (req: MeRequest.Password, res: express.Response) => { + const { currentPassword, newPassword } = req.body; + + if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') { + throw new ResponseHelper.ResponseError('Invalid payload.', undefined, 400); + } + + if (!req.user.password) { + throw new ResponseHelper.ResponseError('Requesting user not set up.'); + } + + const isCurrentPwCorrect = await compare(currentPassword, req.user.password); + if (!isCurrentPwCorrect) { + throw new ResponseHelper.ResponseError( + 'Provided current password is incorrect.', + undefined, + 400, + ); + } + + const validPassword = validatePassword(newPassword); + + req.user.password = hashSync(validPassword, genSaltSync(10)); + + const user = await Db.collections.User!.save(req.user); + Logger.info('Password updated successfully', { userId: user.id }); + + await issueCookie(res, user); + + void InternalHooksManager.getInstance().onUserUpdate({ + user_id: req.user.id, + fields_changed: ['password'], + }); + + return { success: true }; + }), + ); + + /** + * Store the logged-in user's survey answers. + */ + this.app.post( + `/${this.restEndpoint}/me/survey`, + ResponseHelper.send(async (req: MeRequest.SurveyAnswers) => { + const { body: personalizationAnswers } = req; + + if (!personalizationAnswers) { + Logger.debug( + 'Request to store user personalization survey failed because of empty payload', + { + userId: req.user.id, + }, + ); + throw new ResponseHelper.ResponseError( + 'Personalization answers are mandatory', + undefined, + 400, + ); + } + + await Db.collections.User!.save({ + id: req.user.id, + personalizationAnswers, + }); + + Logger.info('User survey updated successfully', { userId: req.user.id }); + + void InternalHooksManager.getInstance().onPersonalizationSurveySubmitted( + req.user.id, + personalizationAnswers, + ); + + return { success: true }; + }), + ); +} diff --git a/packages/cli/src/UserManagement/routes/owner.ts b/packages/cli/src/UserManagement/routes/owner.ts new file mode 100644 index 0000000000..a526afc948 --- /dev/null +++ b/packages/cli/src/UserManagement/routes/owner.ts @@ -0,0 +1,122 @@ +/* eslint-disable import/no-cycle */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { hashSync, genSaltSync } from 'bcryptjs'; +import * as express from 'express'; +import validator from 'validator'; +import { LoggerProxy as Logger } from 'n8n-workflow'; + +import { Db, InternalHooksManager, ResponseHelper } from '../..'; +import config = require('../../../config'); +import { validateEntity } from '../../GenericHelpers'; +import { AuthenticatedRequest, OwnerRequest } from '../../requests'; +import { issueCookie } from '../auth/jwt'; +import { N8nApp } from '../Interfaces'; +import { sanitizeUser, validatePassword } from '../UserManagementHelper'; + +export function ownerNamespace(this: N8nApp): void { + /** + * Promote a shell into the owner of the n8n instance, + * and enable `isInstanceOwnerSetUp` setting. + */ + this.app.post( + `/${this.restEndpoint}/owner`, + ResponseHelper.send(async (req: OwnerRequest.Post, res: express.Response) => { + const { email, firstName, lastName, password } = req.body; + const { id: userId } = req.user; + + if (config.get('userManagement.isInstanceOwnerSetUp')) { + Logger.debug( + 'Request to claim instance ownership failed because instance owner already exists', + { + userId, + }, + ); + throw new ResponseHelper.ResponseError('Invalid request', undefined, 400); + } + + if (!email || !validator.isEmail(email)) { + Logger.debug('Request to claim instance ownership failed because of invalid email', { + userId, + invalidEmail: email, + }); + throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); + } + + const validPassword = validatePassword(password); + + if (!firstName || !lastName) { + Logger.debug( + 'Request to claim instance ownership failed because of missing first name or last name in payload', + { userId, payload: req.body }, + ); + throw new ResponseHelper.ResponseError( + 'First and last names are mandatory', + undefined, + 400, + ); + } + + let owner = await Db.collections.User!.findOne(userId, { + relations: ['globalRole'], + }); + + if (!owner || (owner.globalRole.scope === 'global' && owner.globalRole.name !== 'owner')) { + Logger.debug( + 'Request to claim instance ownership failed because user shell does not exist or has wrong role!', + { + userId, + }, + ); + throw new ResponseHelper.ResponseError('Invalid request', undefined, 400); + } + + owner = Object.assign(owner, { + email, + firstName, + lastName, + password: hashSync(validPassword, genSaltSync(10)), + }); + + await validateEntity(owner); + + owner = await Db.collections.User!.save(owner); + + Logger.info('Owner was set up successfully', { userId: req.user.id }); + + await Db.collections.Settings!.update( + { key: 'userManagement.isInstanceOwnerSetUp' }, + { value: JSON.stringify(true) }, + ); + + config.set('userManagement.isInstanceOwnerSetUp', true); + + Logger.debug('Setting isInstanceOwnerSetUp updated successfully', { userId: req.user.id }); + + await issueCookie(res, owner); + + void InternalHooksManager.getInstance().onInstanceOwnerSetup({ + user_id: userId, + }); + + return sanitizeUser(owner); + }), + ); + + /** + * Persist that the instance owner setup has been skipped + */ + this.app.post( + `/${this.restEndpoint}/owner/skip-setup`, + // eslint-disable-next-line @typescript-eslint/naming-convention + ResponseHelper.send(async (_req: AuthenticatedRequest, _res: express.Response) => { + await Db.collections.Settings!.update( + { key: 'userManagement.skipInstanceOwnerSetup' }, + { value: JSON.stringify(true) }, + ); + + config.set('userManagement.skipInstanceOwnerSetup', true); + + return { success: true }; + }), + ); +} diff --git a/packages/cli/src/UserManagement/routes/passwordReset.ts b/packages/cli/src/UserManagement/routes/passwordReset.ts new file mode 100644 index 0000000000..574551e142 --- /dev/null +++ b/packages/cli/src/UserManagement/routes/passwordReset.ts @@ -0,0 +1,219 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable import/no-cycle */ + +import express = require('express'); +import { v4 as uuid } from 'uuid'; +import { URL } from 'url'; +import { genSaltSync, hashSync } from 'bcryptjs'; +import validator from 'validator'; +import { IsNull, MoreThanOrEqual, Not } from 'typeorm'; +import { LoggerProxy as Logger } from 'n8n-workflow'; + +import { Db, InternalHooksManager, ResponseHelper } from '../..'; +import { N8nApp } from '../Interfaces'; +import { getInstanceBaseUrl, validatePassword } from '../UserManagementHelper'; +import * as UserManagementMailer from '../email'; +import type { PasswordResetRequest } from '../../requests'; +import { issueCookie } from '../auth/jwt'; +import config = require('../../../config'); + +export function passwordResetNamespace(this: N8nApp): void { + /** + * Send a password reset email. + * + * Authless endpoint. + */ + this.app.post( + `/${this.restEndpoint}/forgot-password`, + ResponseHelper.send(async (req: PasswordResetRequest.Email) => { + if (config.get('userManagement.emails.mode') === '') { + Logger.debug('Request to send password reset email failed because emailing was not set up'); + throw new ResponseHelper.ResponseError( + 'Email sending must be set up in order to request a password reset email', + undefined, + 500, + ); + } + + const { email } = req.body; + + if (!email) { + Logger.debug( + 'Request to send password reset email failed because of missing email in payload', + { payload: req.body }, + ); + throw new ResponseHelper.ResponseError('Email is mandatory', undefined, 400); + } + + if (!validator.isEmail(email)) { + Logger.debug( + 'Request to send password reset email failed because of invalid email in payload', + { invalidEmail: email }, + ); + throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); + } + + // User should just be able to reset password if one is already present + const user = await Db.collections.User!.findOne({ email, password: Not(IsNull()) }); + + if (!user || !user.password) { + Logger.debug( + 'Request to send password reset email failed because no user was found for the provided email', + { invalidEmail: email }, + ); + return; + } + + user.resetPasswordToken = uuid(); + + const { id, firstName, lastName, resetPasswordToken } = user; + + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 7200; + + await Db.collections.User!.update(id, { resetPasswordToken, resetPasswordTokenExpiration }); + + const baseUrl = getInstanceBaseUrl(); + const url = new URL(`${baseUrl}/change-password`); + url.searchParams.append('userId', id); + url.searchParams.append('token', resetPasswordToken); + + try { + const mailer = await UserManagementMailer.getInstance(); + await mailer.passwordReset({ + email, + firstName, + lastName, + passwordResetUrl: url.toString(), + domain: baseUrl, + }); + } catch (error) { + void InternalHooksManager.getInstance().onEmailFailed({ + user_id: user.id, + message_type: 'Reset password', + }); + if (error instanceof Error) { + throw new ResponseHelper.ResponseError( + `Please contact your administrator: ${error.message}`, + undefined, + 500, + ); + } + } + + Logger.info('Sent password reset email successfully', { userId: user.id, email }); + void InternalHooksManager.getInstance().onUserTransactionalEmail({ + user_id: id, + message_type: 'Reset password', + }); + + void InternalHooksManager.getInstance().onUserPasswordResetRequestClick({ + user_id: id, + }); + }), + ); + + /** + * Verify password reset token and user ID. + * + * Authless endpoint. + */ + this.app.get( + `/${this.restEndpoint}/resolve-password-token`, + ResponseHelper.send(async (req: PasswordResetRequest.Credentials) => { + const { token: resetPasswordToken, userId: id } = req.query; + + if (!resetPasswordToken || !id) { + Logger.debug( + 'Request to resolve password token failed because of missing password reset token or user ID in query string', + { + queryString: req.query, + }, + ); + throw new ResponseHelper.ResponseError('', undefined, 400); + } + + // Timestamp is saved in seconds + const currentTimestamp = Math.floor(Date.now() / 1000); + + const user = await Db.collections.User!.findOne({ + id, + resetPasswordToken, + resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp), + }); + + if (!user) { + Logger.debug( + 'Request to resolve password token failed because no user was found for the provided user ID and reset password token', + { + userId: id, + resetPasswordToken, + }, + ); + throw new ResponseHelper.ResponseError('', undefined, 404); + } + + Logger.info('Reset-password token resolved successfully', { userId: id }); + void InternalHooksManager.getInstance().onUserPasswordResetEmailClick({ + user_id: id, + }); + }), + ); + + /** + * Verify password reset token and user ID and update password. + * + * Authless endpoint. + */ + this.app.post( + `/${this.restEndpoint}/change-password`, + ResponseHelper.send(async (req: PasswordResetRequest.NewPassword, res: express.Response) => { + const { token: resetPasswordToken, userId, password } = req.body; + + if (!resetPasswordToken || !userId || !password) { + Logger.debug( + 'Request to change password failed because of missing user ID or password or reset password token in payload', + { + payload: req.body, + }, + ); + throw new ResponseHelper.ResponseError( + 'Missing user ID or password or reset password token', + undefined, + 400, + ); + } + + const validPassword = validatePassword(password); + + // Timestamp is saved in seconds + const currentTimestamp = Math.floor(Date.now() / 1000); + + const user = await Db.collections.User!.findOne({ + id: userId, + resetPasswordToken, + resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp), + }); + + if (!user) { + Logger.debug( + 'Request to resolve password token failed because no user was found for the provided user ID and reset password token', + { + userId, + resetPasswordToken, + }, + ); + throw new ResponseHelper.ResponseError('', undefined, 404); + } + + await Db.collections.User!.update(userId, { + password: hashSync(validPassword, genSaltSync(10)), + resetPasswordToken: null, + resetPasswordTokenExpiration: null, + }); + + Logger.info('User password updated successfully', { userId }); + + await issueCookie(res, user); + }), + ); +} diff --git a/packages/cli/src/UserManagement/routes/users.ts b/packages/cli/src/UserManagement/routes/users.ts new file mode 100644 index 0000000000..1e7e41a9f0 --- /dev/null +++ b/packages/cli/src/UserManagement/routes/users.ts @@ -0,0 +1,562 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable import/no-cycle */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { Response } from 'express'; +import { In } from 'typeorm'; +import { genSaltSync, hashSync } from 'bcryptjs'; +import validator from 'validator'; +import { LoggerProxy as Logger } from 'n8n-workflow'; + +import { Db, InternalHooksManager, ITelemetryUserDeletionData, ResponseHelper } from '../..'; +import { N8nApp, PublicUser } from '../Interfaces'; +import { UserRequest } from '../../requests'; +import { + getInstanceBaseUrl, + isEmailSetUp, + sanitizeUser, + validatePassword, +} from '../UserManagementHelper'; +import { User } from '../../databases/entities/User'; +import { SharedWorkflow } from '../../databases/entities/SharedWorkflow'; +import { SharedCredentials } from '../../databases/entities/SharedCredentials'; +import * as UserManagementMailer from '../email/UserManagementMailer'; + +import config = require('../../../config'); +import { issueCookie } from '../auth/jwt'; + +export function usersNamespace(this: N8nApp): void { + /** + * Send email invite(s) to one or multiple users and create user shell(s). + */ + this.app.post( + `/${this.restEndpoint}/users`, + ResponseHelper.send(async (req: UserRequest.Invite) => { + if (config.get('userManagement.emails.mode') === '') { + Logger.debug( + 'Request to send email invite(s) to user(s) failed because emailing was not set up', + ); + throw new ResponseHelper.ResponseError( + 'Email sending must be set up in order to request a password reset email', + undefined, + 500, + ); + } + + let mailer: UserManagementMailer.UserManagementMailer | undefined; + try { + mailer = await UserManagementMailer.getInstance(); + } catch (error) { + if (error instanceof Error) { + throw new ResponseHelper.ResponseError( + `There is a problem with your SMTP setup! ${error.message}`, + undefined, + 500, + ); + } + } + + // TODO: this should be checked in the middleware rather than here + if (config.get('userManagement.disabled')) { + Logger.debug( + 'Request to send email invite(s) to user(s) failed because user management is disabled', + ); + throw new ResponseHelper.ResponseError('User management is disabled'); + } + + if (!config.get('userManagement.isInstanceOwnerSetUp')) { + Logger.debug( + 'Request to send email invite(s) to user(s) failed because the owner account is not set up', + ); + throw new ResponseHelper.ResponseError( + 'You must set up your own account before inviting others', + undefined, + 400, + ); + } + + if (!Array.isArray(req.body)) { + Logger.debug( + 'Request to send email invite(s) to user(s) failed because the payload is not an array', + { + payload: req.body, + }, + ); + throw new ResponseHelper.ResponseError('Invalid payload', undefined, 400); + } + + if (!req.body.length) return []; + + const createUsers: { [key: string]: string | null } = {}; + // Validate payload + req.body.forEach((invite) => { + if (typeof invite !== 'object' || !invite.email) { + throw new ResponseHelper.ResponseError( + 'Request to send email invite(s) to user(s) failed because the payload is not an array shaped Array<{ email: string }>', + undefined, + 400, + ); + } + + if (!validator.isEmail(invite.email)) { + Logger.debug('Invalid email in payload', { invalidEmail: invite.email }); + throw new ResponseHelper.ResponseError( + `Request to send email invite(s) to user(s) failed because of an invalid email address: ${invite.email}`, + undefined, + 400, + ); + } + createUsers[invite.email] = null; + }); + + const role = await Db.collections.Role!.findOne({ scope: 'global', name: 'member' }); + + if (!role) { + Logger.error( + 'Request to send email invite(s) to user(s) failed because no global member role was found in database', + ); + throw new ResponseHelper.ResponseError( + 'Members role not found in database - inconsistent state', + undefined, + 500, + ); + } + + // remove/exclude existing users from creation + const existingUsers = await Db.collections.User!.find({ + where: { email: In(Object.keys(createUsers)) }, + }); + existingUsers.forEach((user) => { + if (user.password) { + delete createUsers[user.email]; + return; + } + createUsers[user.email] = user.id; + }); + + const usersToSetUp = Object.keys(createUsers).filter((email) => createUsers[email] === null); + const total = usersToSetUp.length; + + Logger.debug(total > 1 ? `Creating ${total} user shells...` : `Creating 1 user shell...`); + + try { + await Db.transaction(async (transactionManager) => { + return Promise.all( + usersToSetUp.map(async (email) => { + const newUser = Object.assign(new User(), { + email, + globalRole: role, + }); + const savedUser = await transactionManager.save(newUser); + createUsers[savedUser.email] = savedUser.id; + return savedUser; + }), + ); + }); + + void InternalHooksManager.getInstance().onUserInvite({ + user_id: req.user.id, + target_user_id: Object.values(createUsers) as string[], + }); + } catch (error) { + Logger.error('Failed to create user shells', { userShells: createUsers }); + throw new ResponseHelper.ResponseError('An error occurred during user creation'); + } + + Logger.info('Created user shell(s) successfully', { userId: req.user.id }); + Logger.verbose(total > 1 ? `${total} user shells created` : `1 user shell created`, { + userShells: createUsers, + }); + + const baseUrl = getInstanceBaseUrl(); + + const usersPendingSetup = Object.entries(createUsers).filter(([email, id]) => id && email); + + // send invite email to new or not yet setup users + + const emailingResults = await Promise.all( + usersPendingSetup.map(async ([email, id]) => { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + const inviteAcceptUrl = `${baseUrl}/signup?inviterId=${req.user.id}&inviteeId=${id}`; + const result = await mailer?.invite({ + email, + inviteAcceptUrl, + domain: baseUrl, + }); + const resp: { user: { id: string | null; email: string }; error?: string } = { + user: { + id, + email, + }, + }; + if (result?.success) { + void InternalHooksManager.getInstance().onUserTransactionalEmail({ + user_id: id!, + message_type: 'New user invite', + }); + } else { + void InternalHooksManager.getInstance().onEmailFailed({ + user_id: req.user.id, + message_type: 'New user invite', + }); + Logger.error('Failed to send email', { + userId: req.user.id, + inviteAcceptUrl, + domain: baseUrl, + email, + }); + resp.error = `Email could not be sent`; + } + return resp; + }), + ); + + Logger.debug( + usersPendingSetup.length > 1 + ? `Sent ${usersPendingSetup.length} invite emails successfully` + : `Sent 1 invite email successfully`, + { userShells: createUsers }, + ); + + return emailingResults; + }), + ); + + /** + * Validate invite token to enable invitee to set up their account. + * + * Authless endpoint. + */ + this.app.get( + `/${this.restEndpoint}/resolve-signup-token`, + ResponseHelper.send(async (req: UserRequest.ResolveSignUp) => { + const { inviterId, inviteeId } = req.query; + + if (!inviterId || !inviteeId) { + Logger.debug( + 'Request to resolve signup token failed because of missing user IDs in query string', + { inviterId, inviteeId }, + ); + throw new ResponseHelper.ResponseError('Invalid payload', undefined, 400); + } + + // Postgres validates UUID format + for (const userId of [inviterId, inviteeId]) { + if (!validator.isUUID(userId)) { + Logger.debug('Request to resolve signup token failed because of invalid user ID', { + userId, + }); + throw new ResponseHelper.ResponseError('Invalid userId', undefined, 400); + } + } + + const users = await Db.collections.User!.find({ where: { id: In([inviterId, inviteeId]) } }); + + if (users.length !== 2) { + Logger.debug( + 'Request to resolve signup token failed because the ID of the inviter and/or the ID of the invitee were not found in database', + { inviterId, inviteeId }, + ); + throw new ResponseHelper.ResponseError('Invalid invite URL', undefined, 400); + } + + const invitee = users.find((user) => user.id === inviteeId); + + if (!invitee || invitee.password) { + Logger.error('Invalid invite URL - invitee already setup', { + inviterId, + inviteeId, + }); + throw new ResponseHelper.ResponseError('The invitation was likely either deleted or already claimed', undefined, 400); + } + + const inviter = users.find((user) => user.id === inviterId); + + if (!inviter || !inviter.email || !inviter.firstName) { + Logger.error( + 'Request to resolve signup token failed because inviter does not exist or is not set up', + { + inviterId: inviter?.id, + }, + ); + throw new ResponseHelper.ResponseError('Invalid request', undefined, 400); + } + + void InternalHooksManager.getInstance().onUserInviteEmailClick({ + user_id: inviteeId, + }); + + const { firstName, lastName } = inviter; + + return { inviter: { firstName, lastName } }; + }), + ); + + /** + * Fill out user shell with first name, last name, and password. + * + * Authless endpoint. + */ + this.app.post( + `/${this.restEndpoint}/users/:id`, + ResponseHelper.send(async (req: UserRequest.Update, res: Response) => { + const { id: inviteeId } = req.params; + + const { inviterId, firstName, lastName, password } = req.body; + + if (!inviterId || !inviteeId || !firstName || !lastName || !password) { + Logger.debug( + 'Request to fill out a user shell failed because of missing properties in payload', + { payload: req.body }, + ); + throw new ResponseHelper.ResponseError('Invalid payload', undefined, 400); + } + + const validPassword = validatePassword(password); + + const users = await Db.collections.User!.find({ + where: { id: In([inviterId, inviteeId]) }, + relations: ['globalRole'], + }); + + if (users.length !== 2) { + Logger.debug( + 'Request to fill out a user shell failed because the inviter ID and/or invitee ID were not found in database', + { + inviterId, + inviteeId, + }, + ); + throw new ResponseHelper.ResponseError('Invalid payload or URL', undefined, 400); + } + + const invitee = users.find((user) => user.id === inviteeId) as User; + + if (invitee.password) { + Logger.debug( + 'Request to fill out a user shell failed because the invite had already been accepted', + { inviteeId }, + ); + throw new ResponseHelper.ResponseError( + 'This invite has been accepted already', + undefined, + 400, + ); + } + + invitee.firstName = firstName; + invitee.lastName = lastName; + invitee.password = hashSync(validPassword, genSaltSync(10)); + + const updatedUser = await Db.collections.User!.save(invitee); + + await issueCookie(res, updatedUser); + + void InternalHooksManager.getInstance().onUserSignup({ + user_id: invitee.id, + }); + + return sanitizeUser(updatedUser); + }), + ); + + this.app.get( + `/${this.restEndpoint}/users`, + ResponseHelper.send(async () => { + const users = await Db.collections.User!.find({ relations: ['globalRole'] }); + + return users.map((user): PublicUser => sanitizeUser(user, ['personalizationAnswers'])); + }), + ); + + /** + * Delete a user. Optionally, designate a transferee for their workflows and credentials. + */ + this.app.delete( + `/${this.restEndpoint}/users/:id`, + ResponseHelper.send(async (req: UserRequest.Delete) => { + const { id: idToDelete } = req.params; + + if (req.user.id === idToDelete) { + Logger.debug( + 'Request to delete a user failed because it attempted to delete the requesting user', + { userId: req.user.id }, + ); + throw new ResponseHelper.ResponseError('Cannot delete your own user', undefined, 400); + } + + const { transferId } = req.query; + + if (transferId === idToDelete) { + throw new ResponseHelper.ResponseError( + 'Request to delete a user failed because the user to delete and the transferee are the same user', + undefined, + 400, + ); + } + + const users = await Db.collections.User!.find({ + where: { id: In([transferId, idToDelete]) }, + }); + + if (!users.length || (transferId && users.length !== 2)) { + throw new ResponseHelper.ResponseError( + 'Request to delete a user failed because the ID of the user to delete and/or the ID of the transferee were not found in DB', + undefined, + 404, + ); + } + + const userToDelete = users.find((user) => user.id === req.params.id) as User; + + if (transferId) { + const transferee = users.find((user) => user.id === transferId); + await Db.transaction(async (transactionManager) => { + await transactionManager.update( + SharedWorkflow, + { user: userToDelete }, + { user: transferee }, + ); + await transactionManager.update( + SharedCredentials, + { user: userToDelete }, + { user: transferee }, + ); + await transactionManager.delete(User, { id: userToDelete.id }); + }); + + return { success: true }; + } + + const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([ + Db.collections.SharedWorkflow!.find({ + relations: ['workflow'], + where: { user: userToDelete }, + }), + Db.collections.SharedCredentials!.find({ + relations: ['credentials'], + where: { user: userToDelete }, + }), + ]); + + await Db.transaction(async (transactionManager) => { + const ownedWorkflows = await Promise.all( + ownedSharedWorkflows.map(async ({ workflow }) => { + if (workflow.active) { + // deactivate before deleting + await this.activeWorkflowRunner.remove(workflow.id.toString()); + } + return workflow; + }), + ); + await transactionManager.remove(ownedWorkflows); + await transactionManager.remove( + ownedSharedCredentials.map(({ credentials }) => credentials), + ); + await transactionManager.delete(User, { id: userToDelete.id }); + }); + + const telemetryData: ITelemetryUserDeletionData = { + user_id: req.user.id, + target_user_old_status: userToDelete.isPending ? 'invited' : 'active', + target_user_id: idToDelete, + }; + + telemetryData.migration_strategy = transferId ? 'transfer_data' : 'delete_data'; + + if (transferId) { + telemetryData.migration_user_id = transferId; + } + + void InternalHooksManager.getInstance().onUserDeletion(req.user.id, telemetryData); + + return { success: true }; + }), + ); + + /** + * Resend email invite to user. + */ + this.app.post( + `/${this.restEndpoint}/users/:id/reinvite`, + ResponseHelper.send(async (req: UserRequest.Reinvite) => { + const { id: idToReinvite } = req.params; + + if (!isEmailSetUp()) { + Logger.error('Request to reinvite a user failed because email sending was not set up'); + throw new ResponseHelper.ResponseError( + 'Email sending must be set up in order to invite other users', + undefined, + 500, + ); + } + + const reinvitee = await Db.collections.User!.findOne({ id: idToReinvite }); + + if (!reinvitee) { + Logger.debug( + 'Request to reinvite a user failed because the ID of the reinvitee was not found in database', + ); + throw new ResponseHelper.ResponseError('Could not find user', undefined, 404); + } + + if (reinvitee.password) { + Logger.debug( + 'Request to reinvite a user failed because the invite had already been accepted', + { userId: reinvitee.id }, + ); + throw new ResponseHelper.ResponseError( + 'User has already accepted the invite', + undefined, + 400, + ); + } + + const baseUrl = getInstanceBaseUrl(); + const inviteAcceptUrl = `${baseUrl}/signup?inviterId=${req.user.id}&inviteeId=${reinvitee.id}`; + + let mailer: UserManagementMailer.UserManagementMailer | undefined; + try { + mailer = await UserManagementMailer.getInstance(); + } catch (error) { + if (error instanceof Error) { + throw new ResponseHelper.ResponseError(error.message, undefined, 500); + } + } + + const result = await mailer?.invite({ + email: reinvitee.email, + inviteAcceptUrl, + domain: baseUrl, + }); + + if (!result?.success) { + void InternalHooksManager.getInstance().onEmailFailed({ + user_id: req.user.id, + message_type: 'Resend invite', + }); + Logger.error('Failed to send email', { + email: reinvitee.email, + inviteAcceptUrl, + domain: baseUrl, + }); + throw new ResponseHelper.ResponseError( + `Failed to send email to ${reinvitee.email}`, + undefined, + 500, + ); + } + + void InternalHooksManager.getInstance().onUserReinvite({ + user_id: req.user.id, + target_user_id: reinvitee.id, + }); + + void InternalHooksManager.getInstance().onUserTransactionalEmail({ + user_id: reinvitee.id, + message_type: 'Resend invite', + }); + + return { success: true }; + }), + ); +} diff --git a/packages/cli/src/WaitTracker.ts b/packages/cli/src/WaitTracker.ts index 791ff68e8e..1534ac325b 100644 --- a/packages/cli/src/WaitTracker.ts +++ b/packages/cli/src/WaitTracker.ts @@ -24,6 +24,7 @@ import { WorkflowCredentials, WorkflowRunner, } from '.'; +import { getWorkflowOwner } from './UserManagement/UserManagementHelper'; export class WaitTrackerClass { activeExecutionsInstance: ActiveExecutions.ActiveExecutions; @@ -157,10 +158,16 @@ export class WaitTrackerClass { throw new Error('The execution did succeed and can so not be started again.'); } + if (!fullExecutionData.workflowData.id) { + throw new Error('Only saved workflows can be resumed.'); + } + const user = await getWorkflowOwner(fullExecutionData.workflowData.id.toString()); + const data: IWorkflowExecutionDataProcess = { executionMode: fullExecutionData.mode, executionData: fullExecutionData.data, workflowData: fullExecutionData.workflowData, + userId: user.id, }; // Start the execution again diff --git a/packages/cli/src/WaitingWebhooks.ts b/packages/cli/src/WaitingWebhooks.ts index dd54b37a96..71e7b88a12 100644 --- a/packages/cli/src/WaitingWebhooks.ts +++ b/packages/cli/src/WaitingWebhooks.ts @@ -26,6 +26,7 @@ import { WorkflowCredentials, WorkflowExecuteAdditionalData, } from '.'; +import { getWorkflowOwner } from './UserManagement/UserManagementHelper'; export class WaitingWebhooks { async executeWebhook( @@ -111,7 +112,14 @@ export class WaitingWebhooks { settings: workflowData.settings, }); - const additionalData = await WorkflowExecuteAdditionalData.getBase(); + let workflowOwner; + try { + workflowOwner = await getWorkflowOwner(workflowData.id!.toString()); + } catch (error) { + throw new ResponseHelper.ResponseError('Could not find workflow', undefined, 404); + } + + const additionalData = await WorkflowExecuteAdditionalData.getBase(workflowOwner.id); const webhookData = NodeHelpers.getNodeWebhooks( workflow, diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index 7dfb7fa75c..7b1e0b650b 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable no-param-reassign */ /* eslint-disable @typescript-eslint/prefer-optional-chain */ @@ -53,6 +54,9 @@ import { // eslint-disable-next-line import/no-cycle import * as ActiveExecutions from './ActiveExecutions'; +import { User } from './databases/entities/User'; +import { WorkflowEntity } from './databases/entities/WorkflowEntity'; +import { getWorkflowOwner } from './UserManagement/UserManagementHelper'; const activeExecutions = ActiveExecutions.getInstance(); @@ -223,8 +227,22 @@ export async function executeWebhook( throw new ResponseHelper.ResponseError(errorMessage, 500, 500); } + let user: User; + if ( + (workflowData as WorkflowEntity).shared?.length && + (workflowData as WorkflowEntity).shared[0].user + ) { + user = (workflowData as WorkflowEntity).shared[0].user; + } else { + try { + user = await getWorkflowOwner(workflowData.id.toString()); + } catch (error) { + throw new ResponseHelper.ResponseError('Cannot find workflow', undefined, 404); + } + } + // Prepare everything that is needed to run the workflow - const additionalData = await WorkflowExecuteAdditionalData.getBase(); + const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id); // Add the Response and Request so that this data can be accessed in the node additionalData.httpRequest = req; @@ -404,6 +422,7 @@ export async function executeWebhook( executionData: runExecutionData, sessionId, workflowData, + userId: user.id, }; let responsePromise: IDeferredPromise | undefined; diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 0faada56f1..ee2b227744 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable no-restricted-syntax */ /* eslint-disable @typescript-eslint/restrict-plus-operands */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ @@ -39,7 +40,6 @@ import { import { LessThanOrEqual } from 'typeorm'; import { DateUtils } from 'typeorm/util/DateUtils'; import * as config from '../config'; -// eslint-disable-next-line import/no-cycle import { ActiveExecutions, CredentialsHelper, @@ -60,6 +60,12 @@ import { WorkflowCredentials, WorkflowHelpers, } from '.'; +import { + checkPermissionsForExecution, + getUserById, + getWorkflowOwner, +} from './UserManagement/UserManagementHelper'; +import { whereClause } from './WorkflowHelpers'; const ERROR_TRIGGER_TYPE = config.get('nodes.errorTriggerType') as string; @@ -120,20 +126,36 @@ function executeErrorWorkflow( workflowId: workflowData.id, }); // If a specific error workflow is set run only that one + + // First, do permission checks. + if (!workflowData.id) { + // Manual executions do not trigger error workflows + // So this if should never happen. It was added to + // make sure there are no possible security gaps + return; + } + // eslint-disable-next-line @typescript-eslint/no-floating-promises - WorkflowHelpers.executeErrorWorkflow( - workflowData.settings.errorWorkflow as string, - workflowErrorData, - ); + getWorkflowOwner(workflowData.id).then((user) => { + void WorkflowHelpers.executeErrorWorkflow( + workflowData.settings!.errorWorkflow as string, + workflowErrorData, + user, + ); + }); } else if ( mode !== 'error' && workflowData.id !== undefined && workflowData.nodes.some((node) => node.type === ERROR_TRIGGER_TYPE) ) { Logger.verbose(`Start internal error workflow`, { executionId, workflowId: workflowData.id }); - // If the workflow contains - // eslint-disable-next-line @typescript-eslint/no-floating-promises - WorkflowHelpers.executeErrorWorkflow(workflowData.id.toString(), workflowErrorData); + void getWorkflowOwner(workflowData.id).then((user) => { + void WorkflowHelpers.executeErrorWorkflow( + workflowData.id!.toString(), + workflowErrorData, + user, + ); + }); } } } @@ -698,6 +720,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { export async function getRunData( workflowData: IWorkflowBase, + userId: string, inputData?: INodeExecutionData[], ): Promise { const mode = 'integrated'; @@ -751,27 +774,47 @@ export async function getRunData( executionData: runExecutionData, // @ts-ignore workflowData, + userId, }; return runData; } -export async function getWorkflowData(workflowInfo: IExecuteWorkflowInfo): Promise { +export async function getWorkflowData( + workflowInfo: IExecuteWorkflowInfo, + userId: string, +): Promise { if (workflowInfo.id === undefined && workflowInfo.code === undefined) { throw new Error( `No information about the workflow to execute found. Please provide either the "id" or "code"!`, ); } - if (Db.collections.Workflow === null) { - // The first time executeWorkflow gets called the Database has - // to get initialized first - await Db.init(); - } - let workflowData: IWorkflowBase | undefined; if (workflowInfo.id !== undefined) { - workflowData = await Db.collections.Workflow!.findOne(workflowInfo.id); + if (Db.collections.Workflow === null) { + // The first time executeWorkflow gets called the Database has + // to get initialized first + await Db.init(); + } + const user = await getUserById(userId); + let relations = ['workflow', 'workflow.tags']; + + if (config.get('workflowTagsDisabled')) { + relations = relations.filter((relation) => relation !== 'workflow.tags'); + } + + const shared = await Db.collections.SharedWorkflow!.findOne({ + relations, + where: whereClause({ + user, + entityType: 'workflow', + entityId: workflowInfo.id, + }), + }); + + workflowData = shared?.workflow; + if (workflowData === undefined) { throw new Error(`The workflow with the id "${workflowInfo.id}" does not exist.`); } @@ -805,7 +848,7 @@ export async function executeWorkflow( const nodeTypes = NodeTypes(); const workflowData = - loadedWorkflowData !== undefined ? loadedWorkflowData : await getWorkflowData(workflowInfo); + loadedWorkflowData ?? (await getWorkflowData(workflowInfo, additionalData.userId)); const workflowName = workflowData ? workflowData.name : undefined; const workflow = new Workflow({ @@ -819,7 +862,7 @@ export async function executeWorkflow( }); const runData = - loadedRunData !== undefined ? loadedRunData : await getRunData(workflowData, inputData); + loadedRunData ?? (await getRunData(workflowData, additionalData.userId, inputData)); let executionId; @@ -834,9 +877,11 @@ export async function executeWorkflow( let data; try { + await checkPermissionsForExecution(workflow, additionalData.userId); + // Create new additionalData to have different workflow loaded and to call // different webooks - const additionalDataIntegrated = await getBase(); + const additionalDataIntegrated = await getBase(additionalData.userId); additionalDataIntegrated.hooks = getWorkflowHooksIntegrated( runData.executionMode, executionId, @@ -908,6 +953,9 @@ export async function executeWorkflow( stoppedAt: fullRunData.stoppedAt, workflowData, }; + if (workflowData.id) { + fullExecutionData.workflowId = workflowData.id as string; + } const executionData = ResponseHelper.flattenExecutionData(fullExecutionData); @@ -919,7 +967,12 @@ export async function executeWorkflow( } await externalHooks.run('workflow.postExecute', [data, workflowData, executionId]); - void InternalHooksManager.getInstance().onWorkflowPostExecute(executionId, workflowData, data); + void InternalHooksManager.getInstance().onWorkflowPostExecute( + executionId, + workflowData, + data, + additionalData.userId, + ); if (data.finished === true) { // Workflow did finish successfully @@ -969,6 +1022,7 @@ export function sendMessageToUI(source: string, messages: any[]) { * @returns {Promise} */ export async function getBase( + userId: string, currentNodeParameters?: INodeParameters, executionTimeoutTimestamp?: number, ): Promise { @@ -995,6 +1049,7 @@ export async function getBase( webhookTestBaseUrl, currentNodeParameters, executionTimeoutTimestamp, + userId, }; } diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index 25bac0cbc0..d5a0f60e87 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ @@ -19,7 +20,6 @@ import { LoggerProxy as Logger, Workflow, } from 'n8n-workflow'; -import { validate } from 'class-validator'; // eslint-disable-next-line import/no-cycle import { CredentialTypes, @@ -29,13 +29,15 @@ import { IWorkflowErrorData, IWorkflowExecutionDataProcess, NodeTypes, - ResponseHelper, + WhereClause, WorkflowRunner, } from '.'; import * as config from '../config'; // eslint-disable-next-line import/no-cycle import { WorkflowEntity } from './databases/entities/WorkflowEntity'; +import { User } from './databases/entities/User'; +import { getWorkflowOwner } from './UserManagement/UserManagementHelper'; const ERROR_TRIGGER_TYPE = config.get('nodes.errorTriggerType') as string; @@ -91,10 +93,36 @@ export function isWorkflowIdValid(id: string | null | undefined | number): boole export async function executeErrorWorkflow( workflowId: string, workflowErrorData: IWorkflowErrorData, + runningUser: User, ): Promise { // Wrap everything in try/catch to make sure that no errors bubble up and all get caught here try { - const workflowData = await Db.collections.Workflow!.findOne({ id: Number(workflowId) }); + let workflowData; + if (workflowId.toString() !== workflowErrorData.workflow.id?.toString()) { + // To make this code easier to understand, we split it in 2 parts: + // 1) Fetch the owner of the errored workflows and then + // 2) if now instance owner, then check if the user has access to the + // triggered workflow. + + const user = await getWorkflowOwner(workflowErrorData.workflow.id!); + + if (user.globalRole.name === 'owner') { + workflowData = await Db.collections.Workflow!.findOne({ id: Number(workflowId) }); + } else { + const sharedWorkflowData = await Db.collections.SharedWorkflow!.findOne({ + where: { + workflow: { id: workflowId }, + user, + }, + relations: ['workflow'], + }); + if (sharedWorkflowData) { + workflowData = sharedWorkflowData.workflow; + } + } + } else { + workflowData = await Db.collections.Workflow!.findOne({ id: Number(workflowId) }); + } if (workflowData === undefined) { // The error workflow could not be found @@ -106,6 +134,15 @@ export async function executeErrorWorkflow( return; } + const user = await getWorkflowOwner(workflowId); + if (user.id !== runningUser.id) { + // The error workflow could not be found + Logger.warn( + `An attempt to execute workflow ID ${workflowId} as error workflow was blocked due to wrong permission`, + ); + return; + } + const executionMode = 'error'; const nodeTypes = NodeTypes(); @@ -169,6 +206,7 @@ export async function executeErrorWorkflow( executionMode, executionData: runExecutionData, workflowData, + userId: user.id, }; const workflowRunner = new WorkflowRunner(); @@ -521,34 +559,40 @@ export async function replaceInvalidCredentials(workflow: WorkflowEntity): Promi return workflow; } -// TODO: Deduplicate `validateWorkflow` and `throwDuplicateEntryError` with TagHelpers? +/** + * Build a `where` clause for a TypeORM entity search, + * checking for member access if the user is not an owner. + */ +export function whereClause({ + user, + entityType, + entityId = '', +}: { + user: User; + entityType: 'workflow' | 'credentials'; + entityId?: string; +}): WhereClause { + const where: WhereClause = entityId ? { [entityType]: { id: entityId } } : {}; -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export async function validateWorkflow(newWorkflow: WorkflowEntity) { - const errors = await validate(newWorkflow); - - if (errors.length) { - const validationErrorMessage = Object.values(errors[0].constraints!)[0]; - throw new ResponseHelper.ResponseError(validationErrorMessage, undefined, 400); - } -} - -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function throwDuplicateEntryError(error: Error) { - const errorMessage = error.message.toLowerCase(); - if (errorMessage.includes('unique') || errorMessage.includes('duplicate')) { - throw new ResponseHelper.ResponseError( - 'There is already a workflow with this name', - undefined, - 400, - ); + // TODO: Decide if owner access should be restricted + if (user.globalRole.name !== 'owner') { + where.user = { id: user.id }; } - throw new ResponseHelper.ResponseError(errorMessage, undefined, 400); + return where; } -export type NameRequest = Express.Request & { - query: { - name?: string; - }; -}; +/** + * Get the IDs of the workflows that have been shared with the user. + */ +export async function getSharedWorkflowIds(user: User): Promise { + const sharedWorkflows = await Db.collections.SharedWorkflow!.find({ + relations: ['workflow'], + where: whereClause({ + user, + entityType: 'workflow', + }), + }); + + return sharedWorkflows.map(({ workflow }) => workflow.id); +} diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index ce27cfd2c9..6d4abbaaaf 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -57,6 +57,7 @@ import { } from '.'; import * as Queue from './Queue'; import { InternalHooksManager } from './InternalHooksManager'; +import { checkPermissionsForExecution } from './UserManagement/UserManagementHelper'; export class WorkflowRunner { activeExecutions: ActiveExecutions.ActiveExecutions; @@ -177,6 +178,7 @@ export class WorkflowRunner { executionId!, data.workflowData, executionData, + data.userId, ); }) .catch((error) => { @@ -246,6 +248,7 @@ export class WorkflowRunner { staticData: data.workflowData.staticData, }); const additionalData = await WorkflowExecuteAdditionalData.getBase( + data.userId, undefined, workflowTimeout <= 0 ? undefined : Date.now() + workflowTimeout * 1000, ); @@ -265,6 +268,9 @@ export class WorkflowRunner { `Execution for workflow ${data.workflowData.name} was assigned id ${executionId}`, { executionId }, ); + + await checkPermissionsForExecution(workflow, data.userId); + additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain( data, executionId, diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index 22e9c98bf2..8c2d457c4f 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -52,6 +52,7 @@ import { getLogger } from './Logger'; import * as config from '../config'; import { InternalHooksManager } from './InternalHooksManager'; +import { checkPermissionsForExecution } from './UserManagement/UserManagementHelper'; export class WorkflowRunnerProcess { data: IWorkflowExecutionDataProcessWithExecution | undefined; @@ -88,6 +89,7 @@ export class WorkflowRunnerProcess { LoggerProxy.init(logger); this.data = inputData; + const { userId } = inputData; logger.verbose('Initializing n8n sub-process', { pid: process.pid, @@ -235,7 +237,9 @@ export class WorkflowRunnerProcess { staticData: this.data.workflowData.staticData, settings: this.data.workflowData.settings, }); + await checkPermissionsForExecution(this.workflow, userId); const additionalData = await WorkflowExecuteAdditionalData.getBase( + userId, undefined, workflowTimeout <= 0 ? undefined : Date.now() + workflowTimeout * 1000, ); @@ -273,8 +277,15 @@ export class WorkflowRunnerProcess { additionalData: IWorkflowExecuteAdditionalData, inputData?: INodeExecutionData[] | undefined, ): Promise | IRun> => { - const workflowData = await WorkflowExecuteAdditionalData.getWorkflowData(workflowInfo); - const runData = await WorkflowExecuteAdditionalData.getRunData(workflowData, inputData); + const workflowData = await WorkflowExecuteAdditionalData.getWorkflowData( + workflowInfo, + userId, + ); + const runData = await WorkflowExecuteAdditionalData.getRunData( + workflowData, + additionalData.userId, + inputData, + ); await sendToParentProcess('startExecution', { runData }); const executionId: string = await new Promise((resolve) => { this.executionIdCallback = (executionId: string) => { @@ -300,6 +311,7 @@ export class WorkflowRunnerProcess { executionId, workflowData, result, + additionalData.userId, ); await sendToParentProcess('finishExecution', { executionId, result }); delete this.childExecutions[executionId]; diff --git a/packages/cli/src/api/credentials.api.ts b/packages/cli/src/api/credentials.api.ts new file mode 100644 index 0000000000..e1e6727119 --- /dev/null +++ b/packages/cli/src/api/credentials.api.ts @@ -0,0 +1,419 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable import/no-cycle */ +import express = require('express'); +import { In } from 'typeorm'; +import { UserSettings, Credentials } from 'n8n-core'; +import { INodeCredentialTestResult, LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '../Logger'; + +import { + CredentialsHelper, + Db, + GenericHelpers, + ICredentialsDb, + ICredentialsResponse, + whereClause, + ResponseHelper, +} from '..'; + +import { RESPONSE_ERROR_MESSAGES } from '../constants'; +import { CredentialsEntity } from '../databases/entities/CredentialsEntity'; +import { SharedCredentials } from '../databases/entities/SharedCredentials'; +import { validateEntity } from '../GenericHelpers'; +import type { CredentialRequest } from '../requests'; +import config = require('../../config'); +import { externalHooks } from '../Server'; + +export const credentialsController = express.Router(); + +/** + * Initialize Logger if needed + */ +credentialsController.use((req, res, next) => { + try { + LoggerProxy.getInstance(); + } catch (error) { + LoggerProxy.init(getLogger()); + } + next(); +}); + +/** + * GET /credentials + */ +credentialsController.get( + '/', + ResponseHelper.send(async (req: CredentialRequest.GetAll): Promise => { + let credentials: ICredentialsDb[] = []; + + const filter = req.query.filter ? (JSON.parse(req.query.filter) as Record) : {}; + + try { + if (req.user.globalRole.name === 'owner') { + credentials = await Db.collections.Credentials!.find({ + select: ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt'], + where: filter, + }); + } else { + const shared = await Db.collections.SharedCredentials!.find({ + where: whereClause({ + user: req.user, + entityType: 'credentials', + }), + }); + + if (!shared.length) return []; + + credentials = await Db.collections.Credentials!.find({ + select: ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt'], + where: { + id: In(shared.map(({ credentialId }) => credentialId)), + ...filter, + }, + }); + } + } catch (error) { + LoggerProxy.error('Request to list credentials failed', error); + throw error; + } + + return credentials.map((credential) => { + // eslint-disable-next-line no-param-reassign + credential.id = credential.id.toString(); + return credential as ICredentialsResponse; + }); + }), +); + +/** + * GET /credentials/new + * + * Generate a unique credential name. + */ +credentialsController.get( + '/new', + ResponseHelper.send(async (req: CredentialRequest.NewName): Promise<{ name: string }> => { + const { name: newName } = req.query; + + return GenericHelpers.generateUniqueName( + newName ?? config.get('credentials.defaultName'), + 'credentials', + ); + }), +); + +/** + * POST /credentials/test + * + * Test if a credential is valid. + */ +credentialsController.post( + '/test', + ResponseHelper.send(async (req: CredentialRequest.Test): Promise => { + const { credentials, nodeToTestWith } = req.body; + + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { + return { + status: 'Error', + message: RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + }; + } + + const helper = new CredentialsHelper(encryptionKey); + + return helper.testCredentials(req.user, credentials.type, credentials, nodeToTestWith); + }), +); + +/** + * POST /credentials + */ +credentialsController.post( + '/', + ResponseHelper.send(async (req: CredentialRequest.Create) => { + delete req.body.id; // delete if sent + + const newCredential = new CredentialsEntity(); + + Object.assign(newCredential, req.body); + + await validateEntity(newCredential); + + // Add the added date for node access permissions + for (const nodeAccess of newCredential.nodesAccess) { + nodeAccess.date = new Date(); + } + + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + undefined, + 500, + ); + } + + // Encrypt the data + const coreCredential = new Credentials( + { id: null, name: newCredential.name }, + newCredential.type, + newCredential.nodesAccess, + ); + + // @ts-ignore + coreCredential.setData(newCredential.data, encryptionKey); + + const encryptedData = coreCredential.getDataToSave() as ICredentialsDb; + + Object.assign(newCredential, encryptedData); + + await externalHooks.run('credentials.create', [encryptedData]); + + const role = await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'credential', + }); + + const { id, ...rest } = await Db.transaction(async (transactionManager) => { + const savedCredential = await transactionManager.save(newCredential); + + savedCredential.data = newCredential.data; + + const newSharedCredential = new SharedCredentials(); + + Object.assign(newSharedCredential, { + role, + user: req.user, + credentials: savedCredential, + }); + + await transactionManager.save(newSharedCredential); + + return savedCredential; + }); + LoggerProxy.verbose('New credential created', { + credentialId: newCredential.id, + ownerId: req.user.id, + }); + return { id: id.toString(), ...rest }; + }), +); + +/** + * DELETE /credentials/:id + */ +credentialsController.delete( + '/:id', + ResponseHelper.send(async (req: CredentialRequest.Delete) => { + const { id: credentialId } = req.params; + + const shared = await Db.collections.SharedCredentials!.findOne({ + relations: ['credentials'], + where: whereClause({ + user: req.user, + entityType: 'credentials', + entityId: credentialId, + }), + }); + + if (!shared) { + LoggerProxy.info('Attempt to delete credential blocked due to lack of permissions', { + credentialId, + userId: req.user.id, + }); + throw new ResponseHelper.ResponseError( + `Credential with ID "${credentialId}" could not be found to be deleted.`, + undefined, + 404, + ); + } + + await externalHooks.run('credentials.delete', [credentialId]); + + await Db.collections.Credentials!.remove(shared.credentials); + + return true; + }), +); + +/** + * PATCH /credentials/:id + */ +credentialsController.patch( + '/:id', + ResponseHelper.send(async (req: CredentialRequest.Update): Promise => { + const { id: credentialId } = req.params; + + const updateData = new CredentialsEntity(); + Object.assign(updateData, req.body); + + await validateEntity(updateData); + + const shared = await Db.collections.SharedCredentials!.findOne({ + relations: ['credentials'], + where: whereClause({ + user: req.user, + entityType: 'credentials', + entityId: credentialId, + }), + }); + + if (!shared) { + LoggerProxy.info('Attempt to update credential blocked due to lack of permissions', { + credentialId, + userId: req.user.id, + }); + throw new ResponseHelper.ResponseError( + `Credential with ID "${credentialId}" could not be found to be updated.`, + undefined, + 404, + ); + } + + const { credentials: credential } = shared; + + // Add the date for newly added node access permissions + for (const nodeAccess of updateData.nodesAccess) { + if (!nodeAccess.date) { + nodeAccess.date = new Date(); + } + } + + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + undefined, + 500, + ); + } + + const coreCredential = new Credentials( + { id: credential.id.toString(), name: credential.name }, + credential.type, + credential.nodesAccess, + credential.data, + ); + + const decryptedData = coreCredential.getData(encryptionKey); + + // Do not overwrite the oauth data else data like the access or refresh token would get lost + // everytime anybody changes anything on the credentials even if it is just the name. + if (decryptedData.oauthTokenData) { + // @ts-ignore + updateData.data.oauthTokenData = decryptedData.oauthTokenData; + } + + // Encrypt the data + const credentials = new Credentials( + { id: credentialId, name: updateData.name }, + updateData.type, + updateData.nodesAccess, + ); + + // @ts-ignore + credentials.setData(updateData.data, encryptionKey); + + const newCredentialData = credentials.getDataToSave() as ICredentialsDb; + + // Add special database related data + newCredentialData.updatedAt = new Date(); + + await externalHooks.run('credentials.update', [newCredentialData]); + + // Update the credentials in DB + await Db.collections.Credentials!.update(credentialId, newCredentialData); + + // We sadly get nothing back from "update". Neither if it updated a record + // nor the new value. So query now the updated entry. + const responseData = await Db.collections.Credentials!.findOne(credentialId); + + if (responseData === undefined) { + throw new ResponseHelper.ResponseError( + `Credential ID "${credentialId}" could not be found to be updated.`, + undefined, + 404, + ); + } + + // Remove the encrypted data as it is not needed in the frontend + const { id, data, ...rest } = responseData; + + LoggerProxy.verbose('Credential updated', { credentialId }); + + return { + id: id.toString(), + ...rest, + }; + }), +); + +/** + * GET /credentials/:id + */ +credentialsController.get( + '/:id', + ResponseHelper.send(async (req: CredentialRequest.Get) => { + const { id: credentialId } = req.params; + + const shared = await Db.collections.SharedCredentials!.findOne({ + relations: ['credentials'], + where: whereClause({ + user: req.user, + entityType: 'credentials', + entityId: credentialId, + }), + }); + + if (!shared) { + throw new ResponseHelper.ResponseError( + `Credentials with ID "${credentialId}" could not be found.`, + undefined, + 404, + ); + } + + const { credentials: credential } = shared; + + if (req.query.includeData !== 'true') { + const { data, id, ...rest } = credential; + + return { + id: id.toString(), + ...rest, + }; + } + + const { data, id, ...rest } = credential; + + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + undefined, + 500, + ); + } + + const coreCredential = new Credentials( + { id: credential.id.toString(), name: credential.name }, + credential.type, + credential.nodesAccess, + credential.data, + ); + + return { + id: id.toString(), + data: coreCredential.getData(encryptionKey), + ...rest, + }; + }), +); diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts new file mode 100644 index 0000000000..3cdfbb00c7 --- /dev/null +++ b/packages/cli/src/constants.ts @@ -0,0 +1,8 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +export const RESPONSE_ERROR_MESSAGES = { + NO_CREDENTIAL: 'Credential not found', + NO_ENCRYPTION_KEY: 'Encryption key missing to decrypt credentials', +}; + +export const AUTH_COOKIE_NAME = 'n8n-auth'; diff --git a/packages/cli/src/databases/entities/CredentialsEntity.ts b/packages/cli/src/databases/entities/CredentialsEntity.ts index 9ea39eff25..d67361114d 100644 --- a/packages/cli/src/databases/entities/CredentialsEntity.ts +++ b/packages/cli/src/databases/entities/CredentialsEntity.ts @@ -8,12 +8,15 @@ import { CreateDateColumn, Entity, Index, + OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; +import { IsArray, IsObject, IsString, Length } from 'class-validator'; import config = require('../../../config'); import { DatabaseType, ICredentialsDb } from '../..'; +import { SharedCredentials } from './SharedCredentials'; function resolveDataType(dataType: string) { const dbType = config.get('database.type') as DatabaseType; @@ -51,21 +54,29 @@ export class CredentialsEntity implements ICredentialsDb { @PrimaryGeneratedColumn() id: number; - @Column({ - length: 128, + @Column({ length: 128 }) + @IsString({ message: 'Credential `name` must be of type string.' }) + @Length(3, 128, { + message: 'Credential name must be $constraint1 to $constraint2 characters long.', }) name: string; @Column('text') + @IsObject() data: string; @Index() + @IsString({ message: 'Credential `type` must be of type string.' }) @Column({ length: 128, }) type: string; + @OneToMany(() => SharedCredentials, (sharedCredentials) => sharedCredentials.credentials) + shared: SharedCredentials[]; + @Column(resolveDataType('json')) + @IsArray() nodesAccess: ICredentialNodeAccess[]; @CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() }) diff --git a/packages/cli/src/databases/entities/Role.ts b/packages/cli/src/databases/entities/Role.ts new file mode 100644 index 0000000000..ddc8aca31b --- /dev/null +++ b/packages/cli/src/databases/entities/Role.ts @@ -0,0 +1,77 @@ +/* eslint-disable import/no-cycle */ +import { + BeforeUpdate, + Column, + CreateDateColumn, + Entity, + OneToMany, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; +import { IsDate, IsOptional, IsString, Length } from 'class-validator'; + +import config = require('../../../config'); +import { DatabaseType } from '../../index'; +import { User } from './User'; +import { SharedWorkflow } from './SharedWorkflow'; +import { SharedCredentials } from './SharedCredentials'; + +type RoleScopes = 'global' | 'workflow' | 'credential'; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +function getTimestampSyntax() { + const dbType = config.get('database.type') as DatabaseType; + + const map: { [key in DatabaseType]: string } = { + sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')", + postgresdb: 'CURRENT_TIMESTAMP(3)', + mysqldb: 'CURRENT_TIMESTAMP(3)', + mariadb: 'CURRENT_TIMESTAMP(3)', + }; + + return map[dbType]; +} + +@Entity() +@Unique(['scope', 'name']) +export class Role { + @PrimaryGeneratedColumn() + id: number; + + @Column({ length: 32 }) + @IsString({ message: 'Role name must be of type string.' }) + @Length(1, 32, { message: 'Role name must be 1 to 32 characters long.' }) + name: string; + + @Column() + scope: RoleScopes; + + @OneToMany(() => User, (user) => user.globalRole) + globalForUsers: User[]; + + @CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() }) + @IsOptional() // ignored by validation because set at DB level + @IsDate() + createdAt: Date; + + @UpdateDateColumn({ + precision: 3, + default: () => getTimestampSyntax(), + onUpdate: getTimestampSyntax(), + }) + @IsOptional() // ignored by validation because set at DB level + @IsDate() + updatedAt: Date; + + @OneToMany(() => SharedWorkflow, (sharedWorkflow) => sharedWorkflow.role) + sharedWorkflows: SharedWorkflow[]; + + @OneToMany(() => SharedCredentials, (sharedCredentials) => sharedCredentials.role) + sharedCredentials: SharedCredentials[]; + + @BeforeUpdate() + setUpdateDate(): void { + this.updatedAt = new Date(); + } +} diff --git a/packages/cli/src/databases/entities/Settings.ts b/packages/cli/src/databases/entities/Settings.ts new file mode 100644 index 0000000000..3ae0bdc91f --- /dev/null +++ b/packages/cli/src/databases/entities/Settings.ts @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable import/no-cycle */ + +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +import { ISettingsDb } from '../..'; + +@Entity() +export class Settings implements ISettingsDb { + @PrimaryColumn() + key: string; + + @Column() + value: string; + + @Column() + loadOnStartup: boolean; +} diff --git a/packages/cli/src/databases/entities/SharedCredentials.ts b/packages/cli/src/databases/entities/SharedCredentials.ts new file mode 100644 index 0000000000..43bbc4e924 --- /dev/null +++ b/packages/cli/src/databases/entities/SharedCredentials.ts @@ -0,0 +1,70 @@ +/* eslint-disable import/no-cycle */ +import { + BeforeUpdate, + CreateDateColumn, + Entity, + ManyToOne, + RelationId, + UpdateDateColumn, +} from 'typeorm'; +import { IsDate, IsOptional } from 'class-validator'; + +import config = require('../../../config'); +import { DatabaseType } from '../../index'; +import { CredentialsEntity } from './CredentialsEntity'; +import { User } from './User'; +import { Role } from './Role'; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +function getTimestampSyntax() { + const dbType = config.get('database.type') as DatabaseType; + + const map: { [key in DatabaseType]: string } = { + sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')", + postgresdb: 'CURRENT_TIMESTAMP(3)', + mysqldb: 'CURRENT_TIMESTAMP(3)', + mariadb: 'CURRENT_TIMESTAMP(3)', + }; + + return map[dbType]; +} + +@Entity() +export class SharedCredentials { + @ManyToOne(() => Role, (role) => role.sharedCredentials, { nullable: false }) + role: Role; + + @ManyToOne(() => User, (user) => user.sharedCredentials, { primary: true }) + user: User; + + @RelationId((sharedCredential: SharedCredentials) => sharedCredential.user) + userId: string; + + @ManyToOne(() => CredentialsEntity, (credentials) => credentials.shared, { + primary: true, + onDelete: 'CASCADE', + }) + credentials: CredentialsEntity; + + @RelationId((sharedCredential: SharedCredentials) => sharedCredential.credentials) + credentialId: number; + + @CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() }) + @IsOptional() // ignored by validation because set at DB level + @IsDate() + createdAt: Date; + + @UpdateDateColumn({ + precision: 3, + default: () => getTimestampSyntax(), + onUpdate: getTimestampSyntax(), + }) + @IsOptional() // ignored by validation because set at DB level + @IsDate() + updatedAt: Date; + + @BeforeUpdate() + setUpdateDate(): void { + this.updatedAt = new Date(); + } +} diff --git a/packages/cli/src/databases/entities/SharedWorkflow.ts b/packages/cli/src/databases/entities/SharedWorkflow.ts new file mode 100644 index 0000000000..5e20477e36 --- /dev/null +++ b/packages/cli/src/databases/entities/SharedWorkflow.ts @@ -0,0 +1,70 @@ +/* eslint-disable import/no-cycle */ +import { + BeforeUpdate, + CreateDateColumn, + Entity, + ManyToOne, + RelationId, + UpdateDateColumn, +} from 'typeorm'; +import { IsDate, IsOptional } from 'class-validator'; + +import config = require('../../../config'); +import { DatabaseType } from '../../index'; +import { WorkflowEntity } from './WorkflowEntity'; +import { User } from './User'; +import { Role } from './Role'; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +function getTimestampSyntax() { + const dbType = config.get('database.type') as DatabaseType; + + const map: { [key in DatabaseType]: string } = { + sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')", + postgresdb: 'CURRENT_TIMESTAMP(3)', + mysqldb: 'CURRENT_TIMESTAMP(3)', + mariadb: 'CURRENT_TIMESTAMP(3)', + }; + + return map[dbType]; +} + +@Entity() +export class SharedWorkflow { + @ManyToOne(() => Role, (role) => role.sharedWorkflows, { nullable: false }) + role: Role; + + @ManyToOne(() => User, (user) => user.sharedWorkflows, { primary: true }) + user: User; + + @RelationId((sharedWorkflow: SharedWorkflow) => sharedWorkflow.user) + userId: string; + + @ManyToOne(() => WorkflowEntity, (workflow) => workflow.shared, { + primary: true, + onDelete: 'CASCADE', + }) + workflow: WorkflowEntity; + + @RelationId((sharedWorkflow: SharedWorkflow) => sharedWorkflow.workflow) + workflowId: number; + + @CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() }) + @IsOptional() // ignored by validation because set at DB level + @IsDate() + createdAt: Date; + + @UpdateDateColumn({ + precision: 3, + default: () => getTimestampSyntax(), + onUpdate: getTimestampSyntax(), + }) + @IsOptional() // ignored by validation because set at DB level + @IsDate() + updatedAt: Date; + + @BeforeUpdate() + setUpdateDate(): void { + this.updatedAt = new Date(); + } +} diff --git a/packages/cli/src/databases/entities/TagEntity.ts b/packages/cli/src/databases/entities/TagEntity.ts index a845313ea7..0af6512399 100644 --- a/packages/cli/src/databases/entities/TagEntity.ts +++ b/packages/cli/src/databases/entities/TagEntity.ts @@ -5,9 +5,10 @@ import { Column, CreateDateColumn, Entity, + Generated, Index, ManyToMany, - PrimaryGeneratedColumn, + PrimaryColumn, UpdateDateColumn, } from 'typeorm'; import { IsDate, IsOptional, IsString, Length } from 'class-validator'; @@ -15,6 +16,7 @@ import { IsDate, IsOptional, IsString, Length } from 'class-validator'; import config = require('../../../config'); import { DatabaseType } from '../../index'; import { ITagDb } from '../../Interfaces'; +import { idStringifier } from '../utils/transformers'; import { WorkflowEntity } from './WorkflowEntity'; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types @@ -33,13 +35,16 @@ function getTimestampSyntax() { @Entity() export class TagEntity implements ITagDb { - @PrimaryGeneratedColumn() + @Generated() + @PrimaryColumn({ + transformer: idStringifier, + }) id: number; @Column({ length: 24 }) @Index({ unique: true }) @IsString({ message: 'Tag name must be of type string.' }) - @Length(1, 24, { message: 'Tag name must be 1 to 24 characters long.' }) + @Length(1, 24, { message: 'Tag name must be $constraint1 to $constraint2 characters long.' }) name: string; @CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() }) diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts new file mode 100644 index 0000000000..b345b26534 --- /dev/null +++ b/packages/cli/src/databases/entities/User.ts @@ -0,0 +1,137 @@ +/* eslint-disable import/no-cycle */ +import { + AfterLoad, + AfterUpdate, + BeforeUpdate, + Column, + ColumnOptions, + CreateDateColumn, + Entity, + Index, + OneToMany, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { IsEmail, IsString, Length } from 'class-validator'; +import config = require('../../../config'); +import { DatabaseType, IPersonalizationSurveyAnswers } from '../..'; +import { Role } from './Role'; +import { SharedWorkflow } from './SharedWorkflow'; +import { SharedCredentials } from './SharedCredentials'; +import { NoXss } from '../utils/customValidators'; +import { answersFormatter } from '../utils/transformers'; + +export const MIN_PASSWORD_LENGTH = 8; + +export const MAX_PASSWORD_LENGTH = 64; + +function resolveDataType(dataType: string) { + const dbType = config.get('database.type') as DatabaseType; + + const typeMap: { [key in DatabaseType]: { [key: string]: string } } = { + sqlite: { + json: 'simple-json', + }, + postgresdb: { + datetime: 'timestamptz', + }, + mysqldb: {}, + mariadb: {}, + }; + + return typeMap[dbType][dataType] ?? dataType; +} + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +function getTimestampSyntax() { + const dbType = config.get('database.type') as DatabaseType; + + const map: { [key in DatabaseType]: string } = { + sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')", + postgresdb: 'CURRENT_TIMESTAMP(3)', + mysqldb: 'CURRENT_TIMESTAMP(3)', + mariadb: 'CURRENT_TIMESTAMP(3)', + }; + + return map[dbType]; +} + +@Entity() +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 254 }) + @Index({ unique: true }) + @IsEmail() + email: string; + + @Column({ length: 32, nullable: true }) + @NoXss() + @IsString({ message: 'First name must be of type string.' }) + @Length(1, 32, { message: 'First name must be $constraint1 to $constraint2 characters long.' }) + firstName: string; + + @Column({ length: 32, nullable: true }) + @NoXss() + @IsString({ message: 'Last name must be of type string.' }) + @Length(1, 32, { message: 'Last name must be $constraint1 to $constraint2 characters long.' }) + lastName: string; + + @Column({ nullable: true }) + @IsString({ message: 'Password must be of type string.' }) + password?: string; + + @Column({ type: String, nullable: true }) + resetPasswordToken?: string | null; + + // Expiration timestamp saved in seconds + @Column({ type: Number, nullable: true }) + resetPasswordTokenExpiration?: number | null; + + @Column({ + type: resolveDataType('json') as ColumnOptions['type'], + nullable: true, + transformer: answersFormatter, + }) + personalizationAnswers: IPersonalizationSurveyAnswers | null; + + @ManyToOne(() => Role, (role) => role.globalForUsers, { + cascade: true, + nullable: false, + }) + globalRole: Role; + + @OneToMany(() => SharedWorkflow, (sharedWorkflow) => sharedWorkflow.user) + sharedWorkflows: SharedWorkflow[]; + + @OneToMany(() => SharedCredentials, (sharedCredentials) => sharedCredentials.user) + sharedCredentials: SharedCredentials[]; + + @CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() }) + createdAt: Date; + + @UpdateDateColumn({ + precision: 3, + default: () => getTimestampSyntax(), + onUpdate: getTimestampSyntax(), + }) + updatedAt: Date; + + @BeforeUpdate() + setUpdateDate(): void { + this.updatedAt = new Date(); + } + + /** + * Whether the user is pending setup completion. + */ + isPending: boolean; + + @AfterLoad() + @AfterUpdate() + computeIsPending(): void { + this.isPending = this.password == null; + } +} diff --git a/packages/cli/src/databases/entities/WorkflowEntity.ts b/packages/cli/src/databases/entities/WorkflowEntity.ts index b07c4e6a06..097ae84229 100644 --- a/packages/cli/src/databases/entities/WorkflowEntity.ts +++ b/packages/cli/src/databases/entities/WorkflowEntity.ts @@ -13,6 +13,7 @@ import { Index, JoinTable, ManyToMany, + OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; @@ -20,6 +21,7 @@ import { import config = require('../../../config'); import { DatabaseType, IWorkflowDb } from '../..'; import { TagEntity } from './TagEntity'; +import { SharedWorkflow } from './SharedWorkflow'; function resolveDataType(dataType: string) { const dbType = config.get('database.type') as DatabaseType; @@ -57,8 +59,11 @@ export class WorkflowEntity implements IWorkflowDb { @PrimaryGeneratedColumn() id: number; + // TODO: Add XSS check @Index({ unique: true }) - @Length(1, 128, { message: 'Workflow name must be 1 to 128 characters long.' }) + @Length(1, 128, { + message: 'Workflow name must be $constraint1 to $constraint2 characters long.', + }) @Column({ length: 128 }) name: string; @@ -107,6 +112,9 @@ export class WorkflowEntity implements IWorkflowDb { }) tags: TagEntity[]; + @OneToMany(() => SharedWorkflow, (sharedWorkflow) => sharedWorkflow.workflow) + shared: SharedWorkflow[]; + @BeforeUpdate() setUpdateDate() { this.updatedAt = new Date(); diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index 4e980573e2..1492f8615e 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -5,6 +5,11 @@ import { ExecutionEntity } from './ExecutionEntity'; import { WorkflowEntity } from './WorkflowEntity'; import { WebhookEntity } from './WebhookEntity'; import { TagEntity } from './TagEntity'; +import { User } from './User'; +import { Role } from './Role'; +import { Settings } from './Settings'; +import { SharedWorkflow } from './SharedWorkflow'; +import { SharedCredentials } from './SharedCredentials'; export const entities = { CredentialsEntity, @@ -12,4 +17,9 @@ export const entities = { WorkflowEntity, WebhookEntity, TagEntity, + User, + Role, + Settings, + SharedWorkflow, + SharedCredentials, }; diff --git a/packages/cli/src/databases/mysqldb/migrations/1626183952959-AddWaitColumn.ts b/packages/cli/src/databases/mysqldb/migrations/1626183952959-AddWaitColumn.ts index 0ed0348800..ee2aa560e1 100644 --- a/packages/cli/src/databases/mysqldb/migrations/1626183952959-AddWaitColumn.ts +++ b/packages/cli/src/databases/mysqldb/migrations/1626183952959-AddWaitColumn.ts @@ -6,7 +6,6 @@ export class AddWaitColumnId1626183952959 implements MigrationInterface { async up(queryRunner: QueryRunner): Promise { const tablePrefix = config.get('database.tablePrefix'); - console.log('\n\nINFO: Started with migration for wait functionality.\n Depending on the number of saved executions, that may take a little bit.\n\n'); await queryRunner.query('ALTER TABLE `' + tablePrefix + 'execution_entity` ADD `waitTill` DATETIME NULL'); await queryRunner.query('CREATE INDEX `IDX_' + tablePrefix + 'ca4a71b47f28ac6ea88293a8e2` ON `' + tablePrefix + 'execution_entity` (`waitTill`)'); diff --git a/packages/cli/src/databases/mysqldb/migrations/1630451444017-UpdateWorkflowCredentials.ts b/packages/cli/src/databases/mysqldb/migrations/1630451444017-UpdateWorkflowCredentials.ts index 0061052c2a..3357f86853 100644 --- a/packages/cli/src/databases/mysqldb/migrations/1630451444017-UpdateWorkflowCredentials.ts +++ b/packages/cli/src/databases/mysqldb/migrations/1630451444017-UpdateWorkflowCredentials.ts @@ -9,8 +9,6 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac name = 'UpdateWorkflowCredentials1630451444017'; public async up(queryRunner: QueryRunner): Promise { - console.log('Start migration', this.name); - console.time(this.name); const tablePrefix = config.get('database.tablePrefix'); const helpers = new MigrationHelpers(queryRunner); @@ -145,7 +143,6 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac queryRunner.query(updateQuery, updateParams); } }); - console.timeEnd(this.name); } public async down(queryRunner: QueryRunner): Promise { diff --git a/packages/cli/src/databases/mysqldb/migrations/1644424784709-AddExecutionEntityIndexes.ts b/packages/cli/src/databases/mysqldb/migrations/1644424784709-AddExecutionEntityIndexes.ts index 62ced03077..7f7ce7285f 100644 --- a/packages/cli/src/databases/mysqldb/migrations/1644424784709-AddExecutionEntityIndexes.ts +++ b/packages/cli/src/databases/mysqldb/migrations/1644424784709-AddExecutionEntityIndexes.ts @@ -2,31 +2,70 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; export class AddExecutionEntityIndexes1644424784709 implements MigrationInterface { - name = 'AddExecutionEntityIndexes1644424784709' + name = 'AddExecutionEntityIndexes1644424784709'; - public async up(queryRunner: QueryRunner): Promise { - console.log('\n\nINFO: Started migration for execution entity indexes.\n Depending on the number of saved executions, it may take a while.\n\n'); + public async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); - const tablePrefix = config.get('database.tablePrefix'); - - await queryRunner.query('DROP INDEX `IDX_c4d999a5e90784e8caccf5589d` ON `' + tablePrefix + 'execution_entity`'); - await queryRunner.query('DROP INDEX `IDX_ca4a71b47f28ac6ea88293a8e2` ON `' + tablePrefix + 'execution_entity`'); - await queryRunner.query('CREATE INDEX `IDX_06da892aaf92a48e7d3e400003` ON `' + tablePrefix + 'execution_entity` (`workflowId`, `waitTill`, `id`)'); - await queryRunner.query('CREATE INDEX `IDX_78d62b89dc1433192b86dce18a` ON `' + tablePrefix + 'execution_entity` (`workflowId`, `finished`, `id`)'); - await queryRunner.query('CREATE INDEX `IDX_1688846335d274033e15c846a4` ON `' + tablePrefix + 'execution_entity` (`finished`, `id`)'); - await queryRunner.query('CREATE INDEX `IDX_b94b45ce2c73ce46c54f20b5f9` ON `' + tablePrefix + 'execution_entity` (`waitTill`, `id`)'); - await queryRunner.query('CREATE INDEX `IDX_81fc04c8a17de15835713505e4` ON `' + tablePrefix + 'execution_entity` (`workflowId`, `id`)'); - } - - public async down(queryRunner: QueryRunner): Promise { - const tablePrefix = config.get('database.tablePrefix'); - await queryRunner.query('DROP INDEX `IDX_81fc04c8a17de15835713505e4` ON `' + tablePrefix + 'execution_entity`'); - await queryRunner.query('DROP INDEX `IDX_b94b45ce2c73ce46c54f20b5f9` ON `' + tablePrefix + 'execution_entity`'); - await queryRunner.query('DROP INDEX `IDX_1688846335d274033e15c846a4` ON `' + tablePrefix + 'execution_entity`'); - await queryRunner.query('DROP INDEX `IDX_78d62b89dc1433192b86dce18a` ON `' + tablePrefix + 'execution_entity`'); - await queryRunner.query('DROP INDEX `IDX_06da892aaf92a48e7d3e400003` ON `' + tablePrefix + 'execution_entity`'); - await queryRunner.query('CREATE INDEX `IDX_ca4a71b47f28ac6ea88293a8e2` ON `' + tablePrefix + 'execution_entity` (`waitTill`)'); - await queryRunner.query('CREATE INDEX `IDX_c4d999a5e90784e8caccf5589d` ON `' + tablePrefix + 'execution_entity` (`workflowId`)'); - } + await queryRunner.query( + 'DROP INDEX `IDX_c4d999a5e90784e8caccf5589d` ON `' + tablePrefix + 'execution_entity`', + ); + await queryRunner.query( + 'DROP INDEX `IDX_ca4a71b47f28ac6ea88293a8e2` ON `' + tablePrefix + 'execution_entity`', + ); + await queryRunner.query( + 'CREATE INDEX `IDX_06da892aaf92a48e7d3e400003` ON `' + + tablePrefix + + 'execution_entity` (`workflowId`, `waitTill`, `id`)', + ); + await queryRunner.query( + 'CREATE INDEX `IDX_78d62b89dc1433192b86dce18a` ON `' + + tablePrefix + + 'execution_entity` (`workflowId`, `finished`, `id`)', + ); + await queryRunner.query( + 'CREATE INDEX `IDX_1688846335d274033e15c846a4` ON `' + + tablePrefix + + 'execution_entity` (`finished`, `id`)', + ); + await queryRunner.query( + 'CREATE INDEX `IDX_b94b45ce2c73ce46c54f20b5f9` ON `' + + tablePrefix + + 'execution_entity` (`waitTill`, `id`)', + ); + await queryRunner.query( + 'CREATE INDEX `IDX_81fc04c8a17de15835713505e4` ON `' + + tablePrefix + + 'execution_entity` (`workflowId`, `id`)', + ); + } + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query( + 'DROP INDEX `IDX_81fc04c8a17de15835713505e4` ON `' + tablePrefix + 'execution_entity`', + ); + await queryRunner.query( + 'DROP INDEX `IDX_b94b45ce2c73ce46c54f20b5f9` ON `' + tablePrefix + 'execution_entity`', + ); + await queryRunner.query( + 'DROP INDEX `IDX_1688846335d274033e15c846a4` ON `' + tablePrefix + 'execution_entity`', + ); + await queryRunner.query( + 'DROP INDEX `IDX_78d62b89dc1433192b86dce18a` ON `' + tablePrefix + 'execution_entity`', + ); + await queryRunner.query( + 'DROP INDEX `IDX_06da892aaf92a48e7d3e400003` ON `' + tablePrefix + 'execution_entity`', + ); + await queryRunner.query( + 'CREATE INDEX `IDX_ca4a71b47f28ac6ea88293a8e2` ON `' + + tablePrefix + + 'execution_entity` (`waitTill`)', + ); + await queryRunner.query( + 'CREATE INDEX `IDX_c4d999a5e90784e8caccf5589d` ON `' + + tablePrefix + + 'execution_entity` (`workflowId`)', + ); + } } diff --git a/packages/cli/src/databases/mysqldb/migrations/1646992772331-CreateUserManagement.ts b/packages/cli/src/databases/mysqldb/migrations/1646992772331-CreateUserManagement.ts new file mode 100644 index 0000000000..f0fd8a047c --- /dev/null +++ b/packages/cli/src/databases/mysqldb/migrations/1646992772331-CreateUserManagement.ts @@ -0,0 +1,171 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { v4 as uuid } from 'uuid'; +import config = require('../../../../config'); +import { loadSurveyFromDisk } from '../../utils/migrationHelpers'; + +export class CreateUserManagement1646992772331 implements MigrationInterface { + name = 'CreateUserManagement1646992772331'; + + public async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}role ( + \`id\` int NOT NULL AUTO_INCREMENT, + \`name\` varchar(32) NOT NULL, + \`scope\` varchar(255) NOT NULL, + \`createdAt\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`updatedAt\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (\`id\`), + UNIQUE KEY \`UQ_${tablePrefix}5b49d0f504f7ef31045a1fb2eb8\` (\`scope\`,\`name\`) + ) ENGINE=InnoDB;`, + ); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}user ( + \`id\` VARCHAR(36) NOT NULL, + \`email\` VARCHAR(255) NULL DEFAULT NULL, + \`firstName\` VARCHAR(32) NULL DEFAULT NULL, + \`lastName\` VARCHAR(32) NULL DEFAULT NULL, + \`password\` VARCHAR(255) NULL DEFAULT NULL, + \`resetPasswordToken\` VARCHAR(255) NULL DEFAULT NULL, + \`resetPasswordTokenExpiration\` INT NULL DEFAULT NULL, + \`personalizationAnswers\` TEXT NULL DEFAULT NULL, + \`createdAt\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`updatedAt\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`globalRoleId\` INT NOT NULL, + PRIMARY KEY (\`id\`), + UNIQUE INDEX \`IDX_${tablePrefix}e12875dfb3b1d92d7d7c5377e2\` (\`email\` ASC), + INDEX \`FK_${tablePrefix}f0609be844f9200ff4365b1bb3d\` (\`globalRoleId\` ASC) + ) ENGINE=InnoDB;`, + ); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}user\` ADD CONSTRAINT \`FK_${tablePrefix}f0609be844f9200ff4365b1bb3d\` FOREIGN KEY (\`globalRoleId\`) REFERENCES \`${tablePrefix}role\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}shared_workflow ( + \`createdAt\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`updatedAt\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`roleId\` INT NOT NULL, + \`userId\` VARCHAR(36) NOT NULL, + \`workflowId\` INT NOT NULL, + INDEX \`FK_${tablePrefix}3540da03964527aa24ae014b780x\` (\`roleId\` ASC), + INDEX \`FK_${tablePrefix}82b2fd9ec4e3e24209af8160282x\` (\`userId\` ASC), + INDEX \`FK_${tablePrefix}b83f8d2530884b66a9c848c8b88x\` (\`workflowId\` ASC), + PRIMARY KEY (\`userId\`, \`workflowId\`) + ) ENGINE=InnoDB;`, + ); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}shared_workflow\` ADD CONSTRAINT \`FK_${tablePrefix}3540da03964527aa24ae014b780\` FOREIGN KEY (\`roleId\`) REFERENCES \`${tablePrefix}role\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}shared_workflow\` ADD CONSTRAINT \`FK_${tablePrefix}82b2fd9ec4e3e24209af8160282\` FOREIGN KEY (\`userId\`) REFERENCES \`${tablePrefix}user\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}shared_workflow\` ADD CONSTRAINT \`FK_${tablePrefix}b83f8d2530884b66a9c848c8b88\` FOREIGN KEY (\`workflowId\`) REFERENCES \`${tablePrefix}workflow_entity\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}shared_credentials ( + \`createdAt\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`updatedAt\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`roleId\` INT NOT NULL, + \`userId\` VARCHAR(36) NOT NULL, + \`credentialsId\` INT NOT NULL, + INDEX \`FK_${tablePrefix}c68e056637562000b68f480815a\` (\`roleId\` ASC), + INDEX \`FK_${tablePrefix}484f0327e778648dd04f1d70493\` (\`userId\` ASC), + INDEX \`FK_${tablePrefix}68661def1d4bcf2451ac8dbd949\` (\`credentialsId\` ASC), + PRIMARY KEY (\`userId\`, \`credentialsId\`) + ) ENGINE=InnoDB;`, + ); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}shared_credentials\` ADD CONSTRAINT \`FK_${tablePrefix}484f0327e778648dd04f1d70493\` FOREIGN KEY (\`userId\`) REFERENCES \`${tablePrefix}user\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}shared_credentials\` ADD CONSTRAINT \`FK_${tablePrefix}68661def1d4bcf2451ac8dbd949\` FOREIGN KEY (\`credentialsId\`) REFERENCES \`${tablePrefix}credentials_entity\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}shared_credentials\` ADD CONSTRAINT \`FK_${tablePrefix}c68e056637562000b68f480815a\` FOREIGN KEY (\`roleId\`) REFERENCES \`${tablePrefix}role\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}settings ( + \`key\` VARCHAR(255) NOT NULL, + \`value\` TEXT NOT NULL, + \`loadOnStartup\` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (\`key\`) + ) ENGINE=InnoDB;`, + ); + + await queryRunner.query( + `ALTER TABLE ${tablePrefix}workflow_entity DROP INDEX IDX_${tablePrefix}943d8f922be094eb507cb9a7f9`, + ); + + // Insert initial roles + await queryRunner.query( + `INSERT INTO ${tablePrefix}role (name, scope) VALUES ("owner", "global");`, + ); + + const instanceOwnerRole = await queryRunner.query('SELECT LAST_INSERT_ID() as insertId'); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}role (name, scope) VALUES ("member", "global");`, + ); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}role (name, scope) VALUES ("owner", "workflow");`, + ); + + const workflowOwnerRole = await queryRunner.query('SELECT LAST_INSERT_ID() as insertId'); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}role (name, scope) VALUES ("owner", "credential");`, + ); + + const credentialOwnerRole = await queryRunner.query('SELECT LAST_INSERT_ID() as insertId'); + + const survey = loadSurveyFromDisk(); + + const ownerUserId = uuid(); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}user (id, globalRoleId, personalizationAnswers) values (?, ?, ?)`, + [ownerUserId, instanceOwnerRole[0].insertId, survey], + ); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}shared_workflow (createdAt, updatedAt, roleId, userId, workflowId) select + NOW(), NOW(), '${workflowOwnerRole[0].insertId}', '${ownerUserId}', id FROM ${tablePrefix}workflow_entity`, + ); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}shared_credentials (createdAt, updatedAt, roleId, userId, credentialsId) SELECT NOW(), NOW(), '${credentialOwnerRole[0].insertId}', '${ownerUserId}', id FROM ${tablePrefix} credentials_entity`, + ); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}settings (\`key\`, value, loadOnStartup) VALUES ("userManagement.isInstanceOwnerSetUp", "false", 1), ("userManagement.skipInstanceOwnerSetup", "false", 1)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query( + `ALTER TABLE ${tablePrefix}workflow_entity ADD UNIQUE INDEX \`IDX_${tablePrefix}943d8f922be094eb507cb9a7f9\` (\`name\`)`, + ); + + await queryRunner.query(`DROP TABLE "${tablePrefix}shared_credentials"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}shared_workflow"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}user"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}role"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}settings"`); + } +} diff --git a/packages/cli/src/databases/mysqldb/migrations/index.ts b/packages/cli/src/databases/mysqldb/migrations/index.ts index 3e16cf69a4..16ee37da46 100644 --- a/packages/cli/src/databases/mysqldb/migrations/index.ts +++ b/packages/cli/src/databases/mysqldb/migrations/index.ts @@ -11,6 +11,7 @@ import { CertifyCorrectCollation1623936588000 } from './1623936588000-CertifyCor import { AddWaitColumnId1626183952959 } from './1626183952959-AddWaitColumn'; import { UpdateWorkflowCredentials1630451444017 } from './1630451444017-UpdateWorkflowCredentials'; import { AddExecutionEntityIndexes1644424784709 } from './1644424784709-AddExecutionEntityIndexes'; +import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; export const mysqlMigrations = [ InitialMigration1588157391238, @@ -26,4 +27,5 @@ export const mysqlMigrations = [ AddWaitColumnId1626183952959, UpdateWorkflowCredentials1630451444017, AddExecutionEntityIndexes1644424784709, + CreateUserManagement1646992772331, ]; diff --git a/packages/cli/src/databases/postgresdb/migrations/1620824779533-UniqueWorkflowNames.ts b/packages/cli/src/databases/postgresdb/migrations/1620824779533-UniqueWorkflowNames.ts index ab6adb2995..620dcffb79 100644 --- a/packages/cli/src/databases/postgresdb/migrations/1620824779533-UniqueWorkflowNames.ts +++ b/packages/cli/src/databases/postgresdb/migrations/1620824779533-UniqueWorkflowNames.ts @@ -1,59 +1,70 @@ -import {MigrationInterface, QueryRunner} from "typeorm"; -import config = require("../../../../config"); +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config = require('../../../../config'); export class UniqueWorkflowNames1620824779533 implements MigrationInterface { - name = 'UniqueWorkflowNames1620824779533'; + name = 'UniqueWorkflowNames1620824779533'; - async up(queryRunner: QueryRunner): Promise { - let tablePrefix = config.get('database.tablePrefix'); - const tablePrefixPure = tablePrefix; - const schema = config.get('database.postgresdb.schema'); - if (schema) { - tablePrefix = schema + '.' + tablePrefix; - } + async up(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + const tablePrefixPure = tablePrefix; + const schema = config.get('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } - const workflowNames = await queryRunner.query(` + const workflowNames = await queryRunner.query(` SELECT name FROM ${tablePrefix}workflow_entity `); - for (const { name } of workflowNames) { - const [duplicatesQuery, parameters] = queryRunner.connection.driver.escapeQueryWithParameters(` + for (const { name } of workflowNames) { + const [duplicatesQuery, parameters] = queryRunner.connection.driver.escapeQueryWithParameters( + ` SELECT id, name FROM ${tablePrefix}workflow_entity WHERE name = :name ORDER BY "createdAt" ASC - `, { name }, {}); + `, + { name }, + {}, + ); - const duplicates = await queryRunner.query(duplicatesQuery, parameters); + const duplicates = await queryRunner.query(duplicatesQuery, parameters); - if (duplicates.length > 1) { - await Promise.all(duplicates.map(({ id, name }: { id: number; name: string; }, index: number) => { + if (duplicates.length > 1) { + await Promise.all( + duplicates.map(({ id, name }: { id: number; name: string }, index: number) => { if (index === 0) return Promise.resolve(); - const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(` + const [updateQuery, updateParams] = + queryRunner.connection.driver.escapeQueryWithParameters( + ` UPDATE ${tablePrefix}workflow_entity SET name = :name WHERE id = '${id}' - `, { name: `${name} ${index + 1}`}, {}); + `, + { name: `${name} ${index + 1}` }, + {}, + ); return queryRunner.query(updateQuery, updateParams); - })); - } + }), + ); } - - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab" ON ${tablePrefix}workflow_entity ("name") `); } - async down(queryRunner: QueryRunner): Promise { - let tablePrefix = config.get('database.tablePrefix'); - const tablePrefixPure = tablePrefix; - const schema = config.get('database.postgresdb.schema'); - if (schema) { - tablePrefix = schema + '.' + tablePrefix; - } - - await queryRunner.query(`DROP INDEX "public"."IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab"`); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab" ON ${tablePrefix}workflow_entity ("name") `, + ); + } + async down(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + const tablePrefixPure = tablePrefix; + const schema = config.get('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; } + await queryRunner.query(`DROP INDEX "IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab"`); + } } diff --git a/packages/cli/src/databases/postgresdb/migrations/1626176912946-AddwaitTill.ts b/packages/cli/src/databases/postgresdb/migrations/1626176912946-AddwaitTill.ts index 61f545fd76..b37834fa75 100644 --- a/packages/cli/src/databases/postgresdb/migrations/1626176912946-AddwaitTill.ts +++ b/packages/cli/src/databases/postgresdb/migrations/1626176912946-AddwaitTill.ts @@ -11,7 +11,6 @@ export class AddwaitTill1626176912946 implements MigrationInterface { if (schema) { tablePrefix = schema + '.' + tablePrefix; } - console.log('\n\nINFO: Started with migration for wait functionality.\n Depending on the number of saved executions, that may take a little bit.\n\n'); await queryRunner.query(`ALTER TABLE ${tablePrefix}execution_entity ADD "waitTill" TIMESTAMP`); await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_${tablePrefixPure}ca4a71b47f28ac6ea88293a8e2 ON ${tablePrefix}execution_entity ("waitTill")`); diff --git a/packages/cli/src/databases/postgresdb/migrations/1630419189837-UpdateWorkflowCredentials.ts b/packages/cli/src/databases/postgresdb/migrations/1630419189837-UpdateWorkflowCredentials.ts index ad3e44f0e6..2061241ce0 100644 --- a/packages/cli/src/databases/postgresdb/migrations/1630419189837-UpdateWorkflowCredentials.ts +++ b/packages/cli/src/databases/postgresdb/migrations/1630419189837-UpdateWorkflowCredentials.ts @@ -9,8 +9,6 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac name = 'UpdateWorkflowCredentials1630419189837'; public async up(queryRunner: QueryRunner): Promise { - console.log('Start migration', this.name); - console.time(this.name); let tablePrefix = config.get('database.tablePrefix'); const schema = config.get('database.postgresdb.schema'); if (schema) { @@ -151,7 +149,6 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac queryRunner.query(updateQuery, updateParams); } }); - console.timeEnd(this.name); } public async down(queryRunner: QueryRunner): Promise { diff --git a/packages/cli/src/databases/postgresdb/migrations/1644422880309-AddExecutionEntityIndexes.ts b/packages/cli/src/databases/postgresdb/migrations/1644422880309-AddExecutionEntityIndexes.ts index b0b6814b90..933eb2131e 100644 --- a/packages/cli/src/databases/postgresdb/migrations/1644422880309-AddExecutionEntityIndexes.ts +++ b/packages/cli/src/databases/postgresdb/migrations/1644422880309-AddExecutionEntityIndexes.ts @@ -2,46 +2,75 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; export class AddExecutionEntityIndexes1644422880309 implements MigrationInterface { - name = 'AddExecutionEntityIndexes1644422880309' + name = 'AddExecutionEntityIndexes1644422880309'; - public async up(queryRunner: QueryRunner): Promise { - console.log('\n\nINFO: Started migration for execution entity indexes.\n Depending on the number of saved executions, it may take a while.\n\n'); - - let tablePrefix = config.get('database.tablePrefix'); + public async up(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); const tablePrefixPure = tablePrefix; const schema = config.get('database.postgresdb.schema'); - if (schema) { + if (schema) { tablePrefix = schema + '.' + tablePrefix; } - await queryRunner.query(`DROP INDEX IF EXISTS "${schema}"."IDX_${tablePrefixPure}c4d999a5e90784e8caccf5589d"`); - await queryRunner.query(`DROP INDEX IF EXISTS "${schema}"."IDX_${tablePrefixPure}ca4a71b47f28ac6ea88293a8e2"`); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefixPure}33228da131bb1112247cf52a42" ON ${tablePrefix}execution_entity ("stoppedAt") `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefixPure}58154df94c686818c99fb754ce" ON ${tablePrefix}execution_entity ("workflowId", "waitTill", "id") `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefixPure}4f474ac92be81610439aaad61e" ON ${tablePrefix}execution_entity ("workflowId", "finished", "id") `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefixPure}72ffaaab9f04c2c1f1ea86e662" ON ${tablePrefix}execution_entity ("finished", "id") `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefixPure}85b981df7b444f905f8bf50747" ON ${tablePrefix}execution_entity ("waitTill", "id") `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefixPure}d160d4771aba5a0d78943edbe3" ON ${tablePrefix}execution_entity ("workflowId", "id") `); - } + await queryRunner.query( + `DROP INDEX "${schema}".IDX_${tablePrefixPure}c4d999a5e90784e8caccf5589d`, + ); + await queryRunner.query( + `DROP INDEX "${schema}".IDX_${tablePrefixPure}ca4a71b47f28ac6ea88293a8e2`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}33228da131bb1112247cf52a42" ON ${tablePrefix}execution_entity ("stoppedAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}58154df94c686818c99fb754ce" ON ${tablePrefix}execution_entity ("workflowId", "waitTill", "id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}4f474ac92be81610439aaad61e" ON ${tablePrefix}execution_entity ("workflowId", "finished", "id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}72ffaaab9f04c2c1f1ea86e662" ON ${tablePrefix}execution_entity ("finished", "id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}85b981df7b444f905f8bf50747" ON ${tablePrefix}execution_entity ("waitTill", "id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}d160d4771aba5a0d78943edbe3" ON ${tablePrefix}execution_entity ("workflowId", "id") `, + ); + } - public async down(queryRunner: QueryRunner): Promise { - let tablePrefix = config.get('database.tablePrefix'); + public async down(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); const tablePrefixPure = tablePrefix; const schema = config.get('database.postgresdb.schema'); - if (schema) { + if (schema) { tablePrefix = schema + '.' + tablePrefix; } - await queryRunner.query(`DROP INDEX IF EXISTS "${schema}"."IDX_${tablePrefixPure}d160d4771aba5a0d78943edbe3"`); - await queryRunner.query(`DROP INDEX IF EXISTS "${schema}"."IDX_${tablePrefixPure}85b981df7b444f905f8bf50747"`); - await queryRunner.query(`DROP INDEX IF EXISTS "${schema}"."IDX_${tablePrefixPure}72ffaaab9f04c2c1f1ea86e662"`); - await queryRunner.query(`DROP INDEX IF EXISTS "${schema}"."IDX_${tablePrefixPure}4f474ac92be81610439aaad61e"`); - await queryRunner.query(`DROP INDEX IF EXISTS "${schema}"."IDX_${tablePrefixPure}58154df94c686818c99fb754ce"`); - await queryRunner.query(`DROP INDEX IF EXISTS "${schema}"."IDX_${tablePrefixPure}33228da131bb1112247cf52a42"`); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefixPure}ca4a71b47f28ac6ea88293a8e2" ON ${tablePrefix}execution_entity ("waitTill") `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefixPure}c4d999a5e90784e8caccf5589d" ON ${tablePrefix}execution_entity ("workflowId") `); - } - + await queryRunner.query( + `DROP INDEX "${schema}"."IDX_${tablePrefixPure}d160d4771aba5a0d78943edbe3"`, + ); + await queryRunner.query( + `DROP INDEX "${schema}"."IDX_${tablePrefixPure}85b981df7b444f905f8bf50747"`, + ); + await queryRunner.query( + `DROP INDEX "${schema}"."IDX_${tablePrefixPure}72ffaaab9f04c2c1f1ea86e662"`, + ); + await queryRunner.query( + `DROP INDEX "${schema}"."IDX_${tablePrefixPure}4f474ac92be81610439aaad61e"`, + ); + await queryRunner.query( + `DROP INDEX "${schema}"."IDX_${tablePrefixPure}58154df94c686818c99fb754ce"`, + ); + await queryRunner.query( + `DROP INDEX "${schema}"."IDX_${tablePrefixPure}33228da131bb1112247cf52a42"`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}ca4a71b47f28ac6ea88293a8e2" ON ${tablePrefix}execution_entity ("waitTill") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}c4d999a5e90784e8caccf5589d" ON ${tablePrefix}execution_entity ("workflowId") `, + ); + } } diff --git a/packages/cli/src/databases/postgresdb/migrations/1646992772331-CreateUserManagement.ts b/packages/cli/src/databases/postgresdb/migrations/1646992772331-CreateUserManagement.ts new file mode 100644 index 0000000000..5550f4d464 --- /dev/null +++ b/packages/cli/src/databases/postgresdb/migrations/1646992772331-CreateUserManagement.ts @@ -0,0 +1,160 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { v4 as uuid } from 'uuid'; +import config = require('../../../../config'); +import { loadSurveyFromDisk } from '../../utils/migrationHelpers'; + +export class CreateUserManagement1646992772331 implements MigrationInterface { + name = 'CreateUserManagement1646992772331'; + + public async up(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + const tablePrefixPure = tablePrefix; + const schema = config.get('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}role ( + "id" serial NOT NULL, + "name" VARCHAR(32) NOT NULL, + "scope" VARCHAR(255) NOT NULL, + "createdAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PK_${tablePrefixPure}e853ce24e8200abe5721d2c6ac552b73" PRIMARY KEY ("id"), + CONSTRAINT "UQ_${tablePrefixPure}5b49d0f504f7ef31045a1fb2eb8" UNIQUE ("scope", "name") + );`, + ); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}user ( + "id" UUID NOT NULL DEFAULT uuid_in(overlay(overlay(md5(random()::text || ':' || clock_timestamp()::text) placing '4' from 13) placing to_hex(floor(random()*(11-8+1) + 8)::int)::text from 17)::cstring), + "email" VARCHAR(255), + "firstName" VARCHAR(32), + "lastName" VARCHAR(32), + "password" VARCHAR(255), + "resetPasswordToken" VARCHAR(255), + "resetPasswordTokenExpiration" int, + "personalizationAnswers" text, + "createdAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "globalRoleId" int NOT NULL, + CONSTRAINT "PK_${tablePrefixPure}ea8f538c94b6e352418254ed6474a81f" PRIMARY KEY ("id"), + CONSTRAINT "UQ_${tablePrefixPure}e12875dfb3b1d92d7d7c5377e2" UNIQUE (email), + CONSTRAINT "FK_${tablePrefixPure}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES ${tablePrefix}role (id) + );`, + ); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}shared_workflow ( + "createdAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "roleId" INT NOT NULL, + "userId" UUID NOT NULL, + "workflowId" INT NOT NULL, + CONSTRAINT "PK_${tablePrefixPure}cc5d5a71c7b2591f5154ffb0c785e85e" PRIMARY KEY ("userId", "workflowId"), + CONSTRAINT "FK_${tablePrefixPure}3540da03964527aa24ae014b780" FOREIGN KEY ("roleId") REFERENCES ${tablePrefix}role ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT "FK_${tablePrefixPure}82b2fd9ec4e3e24209af8160282" FOREIGN KEY ("userId") REFERENCES ${tablePrefix}user ("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_${tablePrefixPure}b83f8d2530884b66a9c848c8b88" FOREIGN KEY ("workflowId") REFERENCES + ${tablePrefixPure}workflow_entity ("id") ON DELETE CASCADE ON UPDATE NO ACTION + );`, + ); + + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}65a0933c0f19d278881653bf81d35064" ON "shared_workflow" ("workflowId");`, + ); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}shared_credentials ( + "createdAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "roleId" INT NOT NULL, + "userId" UUID NOT NULL, + "credentialsId" INT NOT NULL, + CONSTRAINT "PK_${tablePrefixPure}10dd1527ffb639609be7aadd98f628c6" PRIMARY KEY ("userId", "credentialsId"), + CONSTRAINT "FK_${tablePrefixPure}c68e056637562000b68f480815a" FOREIGN KEY ("roleId") REFERENCES ${tablePrefix}role ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT "FK_${tablePrefixPure}484f0327e778648dd04f1d70493" FOREIGN KEY ("userId") REFERENCES ${tablePrefix}user ("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_${tablePrefixPure}68661def1d4bcf2451ac8dbd949" FOREIGN KEY ("credentialsId") REFERENCES ${tablePrefix}credentials_entity ("id") ON DELETE CASCADE ON UPDATE NO ACTION + );`, + ); + + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}829d16efa0e265cb076d50eca8d21733" ON ${tablePrefix}shared_credentials ("credentialsId");`, + ); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}settings ( + "key" VARCHAR(255) NOT NULL, + "value" TEXT NOT NULL, + "loadOnStartup" boolean NOT NULL DEFAULT false, + CONSTRAINT "PK_${tablePrefixPure}dc0fe14e6d9943f268e7b119f69ab8bd" PRIMARY KEY ("key") + );`, + ); + + await queryRunner.query(`DROP INDEX "IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab"`); + + // Insert initial roles + await queryRunner.query( + `INSERT INTO ${tablePrefix}role (name, scope) VALUES ('owner', 'global');`, + ); + + const instanceOwnerRole = await queryRunner.query('SELECT lastval() as "insertId"'); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}role (name, scope) VALUES ('member', 'global');`, + ); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}role (name, scope) VALUES ('owner', 'workflow');`, + ); + + const workflowOwnerRole = await queryRunner.query('SELECT lastval() as "insertId"'); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}role (name, scope) VALUES ('owner', 'credential');`, + ); + + const credentialOwnerRole = await queryRunner.query('SELECT lastval() as "insertId"'); + + const survey = loadSurveyFromDisk(); + + const ownerUserId = uuid(); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}user ("id", "globalRoleId", "personalizationAnswers") values ($1, $2, $3)`, + [ownerUserId, instanceOwnerRole[0].insertId, survey], + ); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}shared_workflow ("createdAt", "updatedAt", "roleId", "userId", "workflowId") select + NOW(), NOW(), '${workflowOwnerRole[0].insertId}', '${ownerUserId}', "id" FROM ${tablePrefix}workflow_entity`, + ); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}shared_credentials ("createdAt", "updatedAt", "roleId", "userId", "credentialsId") SELECT NOW(), NOW(), '${credentialOwnerRole[0].insertId}', '${ownerUserId}', "id" FROM ${tablePrefix} credentials_entity`, + ); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}settings ("key", "value", "loadOnStartup") VALUES ('userManagement.isInstanceOwnerSetUp', 'false', true), ('userManagement.skipInstanceOwnerSetup', 'false', true)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + const tablePrefixPure = tablePrefix; + const schema = config.get('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } + + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab" ON ${tablePrefix}workflow_entity ("name")`, + ); + + await queryRunner.query(`DROP TABLE ${tablePrefix}shared_credentials`); + await queryRunner.query(`DROP TABLE ${tablePrefix}shared_workflow`); + await queryRunner.query(`DROP TABLE ${tablePrefix}user`); + await queryRunner.query(`DROP TABLE ${tablePrefix}role`); + await queryRunner.query(`DROP TABLE ${tablePrefix}settings`); + } +} diff --git a/packages/cli/src/databases/postgresdb/migrations/index.ts b/packages/cli/src/databases/postgresdb/migrations/index.ts index c1aaac39fa..903b6a7e72 100644 --- a/packages/cli/src/databases/postgresdb/migrations/index.ts +++ b/packages/cli/src/databases/postgresdb/migrations/index.ts @@ -9,6 +9,7 @@ import { AddwaitTill1626176912946 } from './1626176912946-AddwaitTill'; import { UpdateWorkflowCredentials1630419189837 } from './1630419189837-UpdateWorkflowCredentials'; import { AddExecutionEntityIndexes1644422880309 } from './1644422880309-AddExecutionEntityIndexes'; import { IncreaseTypeVarcharLimit1646834195327 } from './1646834195327-IncreaseTypeVarcharLimit'; +import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; export const postgresMigrations = [ InitialMigration1587669153312, @@ -22,4 +23,5 @@ export const postgresMigrations = [ UpdateWorkflowCredentials1630419189837, AddExecutionEntityIndexes1644422880309, IncreaseTypeVarcharLimit1646834195327, + CreateUserManagement1646992772331, ]; diff --git a/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts b/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts index 321560fc90..e0d192c65c 100644 --- a/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts +++ b/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts @@ -1,21 +1,37 @@ -import { - MigrationInterface, - QueryRunner, -} from 'typeorm'; - +import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class InitialMigration1588102412422 implements MigrationInterface { name = 'InitialMigration1588102412422'; async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); - await queryRunner.query(`CREATE TABLE IF NOT EXISTS "${tablePrefix}credentials_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(128) NOT NULL, "nodesAccess" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`, undefined); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8" ON "${tablePrefix}credentials_entity" ("type") `, undefined); - await queryRunner.query(`CREATE TABLE IF NOT EXISTS "${tablePrefix}execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime NOT NULL, "workflowData" text NOT NULL, "workflowId" varchar)`, undefined); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}c4d999a5e90784e8caccf5589d" ON "${tablePrefix}execution_entity" ("workflowId") `, undefined); - await queryRunner.query(`CREATE TABLE IF NOT EXISTS "${tablePrefix}workflow_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text NOT NULL, "connections" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL, "settings" text, "staticData" text)`, undefined); + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "${tablePrefix}credentials_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(128) NOT NULL, "nodesAccess" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8" ON "${tablePrefix}credentials_entity" ("type") `, + undefined, + ); + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "${tablePrefix}execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime NOT NULL, "workflowData" text NOT NULL, "workflowId" varchar)`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}c4d999a5e90784e8caccf5589d" ON "${tablePrefix}execution_entity" ("workflowId") `, + undefined, + ); + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "${tablePrefix}workflow_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text NOT NULL, "connections" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL, "settings" text, "staticData" text)`, + undefined, + ); + + logMigrationEnd(this.name); } async down(queryRunner: QueryRunner): Promise { @@ -27,5 +43,4 @@ export class InitialMigration1588102412422 implements MigrationInterface { await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8"`, undefined); await queryRunner.query(`DROP TABLE "${tablePrefix}credentials_entity"`, undefined); } - } diff --git a/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts b/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts index b011061e28..43d38bd50c 100644 --- a/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts +++ b/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts @@ -1,17 +1,20 @@ -import { - MigrationInterface, - QueryRunner, -} from 'typeorm'; - +import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class WebhookModel1592445003908 implements MigrationInterface { name = 'WebhookModel1592445003908'; async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); - await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}webhook_entity ("workflowId" integer NOT NULL, "webhookPath" varchar NOT NULL, "method" varchar NOT NULL, "node" varchar NOT NULL, PRIMARY KEY ("webhookPath", "method"))`); + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS ${tablePrefix}webhook_entity ("workflowId" integer NOT NULL, "webhookPath" varchar NOT NULL, "method" varchar NOT NULL, "node" varchar NOT NULL, PRIMARY KEY ("webhookPath", "method"))`, + ); + + logMigrationEnd(this.name); } async down(queryRunner: QueryRunner): Promise { diff --git a/packages/cli/src/databases/sqlite/migrations/1594825041918-CreateIndexStoppedAt.ts b/packages/cli/src/databases/sqlite/migrations/1594825041918-CreateIndexStoppedAt.ts index 7c8104f06a..92fe62984e 100644 --- a/packages/cli/src/databases/sqlite/migrations/1594825041918-CreateIndexStoppedAt.ts +++ b/packages/cli/src/databases/sqlite/migrations/1594825041918-CreateIndexStoppedAt.ts @@ -1,14 +1,20 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - +import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class CreateIndexStoppedAt1594825041918 implements MigrationInterface { name = 'CreateIndexStoppedAt1594825041918'; async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1" ON "${tablePrefix}execution_entity" ("stoppedAt") `); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1" ON "${tablePrefix}execution_entity" ("stoppedAt") `, + ); + + logMigrationEnd(this.name); } async down(queryRunner: QueryRunner): Promise { @@ -16,5 +22,4 @@ export class CreateIndexStoppedAt1594825041918 implements MigrationInterface { await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1"`); } - } diff --git a/packages/cli/src/databases/sqlite/migrations/1607431743769-MakeStoppedAtNullable.ts b/packages/cli/src/databases/sqlite/migrations/1607431743769-MakeStoppedAtNullable.ts index 29285c9192..a2aee48c64 100644 --- a/packages/cli/src/databases/sqlite/migrations/1607431743769-MakeStoppedAtNullable.ts +++ b/packages/cli/src/databases/sqlite/migrations/1607431743769-MakeStoppedAtNullable.ts @@ -1,10 +1,13 @@ -import {MigrationInterface, QueryRunner} from "typeorm"; - +import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class MakeStoppedAtNullable1607431743769 implements MigrationInterface { + name = 'MakeStoppedAtNullable1607431743769'; async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); // SQLite does not allow us to simply "alter column" // We're hacking the way sqlite identifies tables @@ -12,12 +15,16 @@ export class MakeStoppedAtNullable1607431743769 implements MigrationInterface { // This is a very strict case when this can be done safely // As no collateral effects exist. await queryRunner.query(`PRAGMA writable_schema = 1; `, undefined); - await queryRunner.query(`UPDATE SQLITE_MASTER SET SQL = 'CREATE TABLE IF NOT EXISTS "${tablePrefix}execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime, "workflowData" text NOT NULL, "workflowId" varchar)' WHERE NAME = "${tablePrefix}execution_entity";`, undefined); + await queryRunner.query( + `UPDATE SQLITE_MASTER SET SQL = 'CREATE TABLE IF NOT EXISTS "${tablePrefix}execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime, "workflowData" text NOT NULL, "workflowId" varchar)' WHERE NAME = "${tablePrefix}execution_entity";`, + undefined, + ); await queryRunner.query(`PRAGMA writable_schema = 0;`, undefined); + + logMigrationEnd(this.name); } async down(queryRunner: QueryRunner): Promise { // This cannot be undone as the table might already have nullable values } - } diff --git a/packages/cli/src/databases/sqlite/migrations/1611071044839-AddWebhookId.ts b/packages/cli/src/databases/sqlite/migrations/1611071044839-AddWebhookId.ts index c4489ad6a6..adf62f76c9 100644 --- a/packages/cli/src/databases/sqlite/migrations/1611071044839-AddWebhookId.ts +++ b/packages/cli/src/databases/sqlite/migrations/1611071044839-AddWebhookId.ts @@ -1,26 +1,45 @@ -import {MigrationInterface, QueryRunner} from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class AddWebhookId1611071044839 implements MigrationInterface { name = 'AddWebhookId1611071044839'; async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); - await queryRunner.query(`CREATE TABLE "temporary_webhook_entity" ("workflowId" integer NOT NULL, "webhookPath" varchar NOT NULL, "method" varchar NOT NULL, "node" varchar NOT NULL, "webhookId" varchar, "pathLength" integer, PRIMARY KEY ("webhookPath", "method"))`); - await queryRunner.query(`INSERT INTO "temporary_webhook_entity"("workflowId", "webhookPath", "method", "node") SELECT "workflowId", "webhookPath", "method", "node" FROM "${tablePrefix}webhook_entity"`); + await queryRunner.query( + `CREATE TABLE "temporary_webhook_entity" ("workflowId" integer NOT NULL, "webhookPath" varchar NOT NULL, "method" varchar NOT NULL, "node" varchar NOT NULL, "webhookId" varchar, "pathLength" integer, PRIMARY KEY ("webhookPath", "method"))`, + ); + await queryRunner.query( + `INSERT INTO "temporary_webhook_entity"("workflowId", "webhookPath", "method", "node") SELECT "workflowId", "webhookPath", "method", "node" FROM "${tablePrefix}webhook_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}webhook_entity"`); - await queryRunner.query(`ALTER TABLE "temporary_webhook_entity" RENAME TO "${tablePrefix}webhook_entity"`); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}742496f199721a057051acf4c2" ON "${tablePrefix}webhook_entity" ("webhookId", "method", "pathLength") `); + await queryRunner.query( + `ALTER TABLE "temporary_webhook_entity" RENAME TO "${tablePrefix}webhook_entity"`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}742496f199721a057051acf4c2" ON "${tablePrefix}webhook_entity" ("webhookId", "method", "pathLength") `, + ); + + logMigrationEnd(this.name); } async down(queryRunner: QueryRunner): Promise { const tablePrefix = config.get('database.tablePrefix'); await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}742496f199721a057051acf4c2"`); - await queryRunner.query(`ALTER TABLE "${tablePrefix}webhook_entity" RENAME TO "temporary_webhook_entity"`); - await queryRunner.query(`CREATE TABLE "${tablePrefix}webhook_entity" ("workflowId" integer NOT NULL, "webhookPath" varchar NOT NULL, "method" varchar NOT NULL, "node" varchar NOT NULL, PRIMARY KEY ("webhookPath", "method"))`); - await queryRunner.query(`INSERT INTO "${tablePrefix}webhook_entity"("workflowId", "webhookPath", "method", "node") SELECT "workflowId", "webhookPath", "method", "node" FROM "temporary_webhook_entity"`); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}webhook_entity" RENAME TO "temporary_webhook_entity"`, + ); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}webhook_entity" ("workflowId" integer NOT NULL, "webhookPath" varchar NOT NULL, "method" varchar NOT NULL, "node" varchar NOT NULL, PRIMARY KEY ("webhookPath", "method"))`, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}webhook_entity"("workflowId", "webhookPath", "method", "node") SELECT "workflowId", "webhookPath", "method", "node" FROM "temporary_webhook_entity"`, + ); await queryRunner.query(`DROP TABLE "temporary_webhook_entity"`); } } diff --git a/packages/cli/src/databases/sqlite/migrations/1617213344594-CreateTagEntity.ts b/packages/cli/src/databases/sqlite/migrations/1617213344594-CreateTagEntity.ts index 3f52eed827..547cc31127 100644 --- a/packages/cli/src/databases/sqlite/migrations/1617213344594-CreateTagEntity.ts +++ b/packages/cli/src/databases/sqlite/migrations/1617213344594-CreateTagEntity.ts @@ -1,38 +1,75 @@ -import {MigrationInterface, QueryRunner} from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class CreateTagEntity1617213344594 implements MigrationInterface { name = 'CreateTagEntity1617213344594'; async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); // create tags table + relationship with workflow entity - await queryRunner.query(`CREATE TABLE "${tablePrefix}tag_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b" ON "${tablePrefix}tag_entity" ("name") `); - await queryRunner.query(`CREATE TABLE "${tablePrefix}workflows_tags" ("workflowId" integer NOT NULL, "tagId" integer NOT NULL, CONSTRAINT "FK_54b2f0343d6a2078fa137443869" FOREIGN KEY ("workflowId") REFERENCES "${tablePrefix}workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_77505b341625b0b4768082e2171" FOREIGN KEY ("tagId") REFERENCES "${tablePrefix}tag_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("workflowId", "tagId"))`); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}54b2f0343d6a2078fa13744386" ON "${tablePrefix}workflows_tags" ("workflowId") `); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}77505b341625b0b4768082e217" ON "${tablePrefix}workflows_tags" ("tagId") `); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}tag_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b" ON "${tablePrefix}tag_entity" ("name") `, + ); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}workflows_tags" ("workflowId" integer NOT NULL, "tagId" integer NOT NULL, CONSTRAINT "FK_54b2f0343d6a2078fa137443869" FOREIGN KEY ("workflowId") REFERENCES "${tablePrefix}workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_77505b341625b0b4768082e2171" FOREIGN KEY ("tagId") REFERENCES "${tablePrefix}tag_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("workflowId", "tagId"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}54b2f0343d6a2078fa13744386" ON "${tablePrefix}workflows_tags" ("workflowId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}77505b341625b0b4768082e217" ON "${tablePrefix}workflows_tags" ("tagId") `, + ); // set default dates for `createdAt` and `updatedAt` await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8"`); - await queryRunner.query(`CREATE TABLE "${tablePrefix}temporary_credentials_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(32) NOT NULL, "nodesAccess" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')))`); - await queryRunner.query(`INSERT INTO "${tablePrefix}temporary_credentials_entity"("id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt") SELECT "id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt" FROM "${tablePrefix}credentials_entity"`); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}temporary_credentials_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(32) NOT NULL, "nodesAccess" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')))`, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}temporary_credentials_entity"("id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt") SELECT "id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt" FROM "${tablePrefix}credentials_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}credentials_entity"`); - await queryRunner.query(`ALTER TABLE "${tablePrefix}temporary_credentials_entity" RENAME TO "${tablePrefix}credentials_entity"`); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8" ON "${tablePrefix}credentials_entity" ("type") `); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}temporary_credentials_entity" RENAME TO "${tablePrefix}credentials_entity"`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8" ON "${tablePrefix}credentials_entity" ("type") `, + ); await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b"`); - await queryRunner.query(`CREATE TABLE "${tablePrefix}temporary_tag_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')))`); - await queryRunner.query(`INSERT INTO "${tablePrefix}temporary_tag_entity"("id", "name", "createdAt", "updatedAt") SELECT "id", "name", "createdAt", "updatedAt" FROM "${tablePrefix}tag_entity"`); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}temporary_tag_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')))`, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}temporary_tag_entity"("id", "name", "createdAt", "updatedAt") SELECT "id", "name", "createdAt", "updatedAt" FROM "${tablePrefix}tag_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}tag_entity"`); - await queryRunner.query(`ALTER TABLE "${tablePrefix}temporary_tag_entity" RENAME TO "${tablePrefix}tag_entity"`); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b" ON "${tablePrefix}tag_entity" ("name") `); - await queryRunner.query(`CREATE TABLE "${tablePrefix}temporary_workflow_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text, "connections" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "settings" text, "staticData" text)`); - await queryRunner.query(`INSERT INTO "${tablePrefix}temporary_workflow_entity"("id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData") SELECT "id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData" FROM "${tablePrefix}workflow_entity"`); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}temporary_tag_entity" RENAME TO "${tablePrefix}tag_entity"`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b" ON "${tablePrefix}tag_entity" ("name") `, + ); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}temporary_workflow_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text, "connections" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "settings" text, "staticData" text)`, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}temporary_workflow_entity"("id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData") SELECT "id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData" FROM "${tablePrefix}workflow_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}workflow_entity"`); - await queryRunner.query(`ALTER TABLE "${tablePrefix}temporary_workflow_entity" RENAME TO "${tablePrefix}workflow_entity"`); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}temporary_workflow_entity" RENAME TO "${tablePrefix}workflow_entity"`, + ); + + logMigrationEnd(this.name); } async down(queryRunner: QueryRunner): Promise { @@ -40,22 +77,44 @@ export class CreateTagEntity1617213344594 implements MigrationInterface { // `createdAt` and `updatedAt` - await queryRunner.query(`ALTER TABLE "${tablePrefix}workflow_entity" RENAME TO "${tablePrefix}temporary_workflow_entity"`); - await queryRunner.query(`CREATE TABLE "${tablePrefix}workflow_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text NOT NULL, "connections" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL, "settings" text, "staticData" text)`); - await queryRunner.query(`INSERT INTO "${tablePrefix}workflow_entity"("id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData") SELECT "id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData" FROM "${tablePrefix}temporary_workflow_entity"`); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}workflow_entity" RENAME TO "${tablePrefix}temporary_workflow_entity"`, + ); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}workflow_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text NOT NULL, "connections" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL, "settings" text, "staticData" text)`, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}workflow_entity"("id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData") SELECT "id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData" FROM "${tablePrefix}temporary_workflow_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}temporary_workflow_entity"`); await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b"`); - await queryRunner.query(`ALTER TABLE "${tablePrefix}tag_entity" RENAME TO "${tablePrefix}temporary_tag_entity"`); - await queryRunner.query(`CREATE TABLE "${tablePrefix}tag_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`); - await queryRunner.query(`INSERT INTO "${tablePrefix}tag_entity"("id", "name", "createdAt", "updatedAt") SELECT "id", "name", "createdAt", "updatedAt" FROM "${tablePrefix}temporary_tag_entity"`); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}tag_entity" RENAME TO "${tablePrefix}temporary_tag_entity"`, + ); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}tag_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}tag_entity"("id", "name", "createdAt", "updatedAt") SELECT "id", "name", "createdAt", "updatedAt" FROM "${tablePrefix}temporary_tag_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}temporary_tag_entity"`); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b" ON "${tablePrefix}tag_entity" ("name") `); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b" ON "${tablePrefix}tag_entity" ("name") `, + ); await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8"`); - await queryRunner.query(`ALTER TABLE "${tablePrefix}credentials_entity" RENAME TO "temporary_credentials_entity"`); - await queryRunner.query(`CREATE TABLE "${tablePrefix}credentials_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(32) NOT NULL, "nodesAccess" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`); - await queryRunner.query(`INSERT INTO "${tablePrefix}credentials_entity"("id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt") SELECT "id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt" FROM "${tablePrefix}temporary_credentials_entity"`); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}credentials_entity" RENAME TO "temporary_credentials_entity"`, + ); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}credentials_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(32) NOT NULL, "nodesAccess" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}credentials_entity"("id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt") SELECT "id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt" FROM "${tablePrefix}temporary_credentials_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}temporary_credentials_entity"`); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8" ON "credentials_entity" ("type") `); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8" ON "credentials_entity" ("type") `, + ); // tags @@ -65,5 +124,4 @@ export class CreateTagEntity1617213344594 implements MigrationInterface { await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b"`); await queryRunner.query(`DROP TABLE "${tablePrefix}tag_entity"`); } - } diff --git a/packages/cli/src/databases/sqlite/migrations/1620821879465-UniqueWorkflowNames.ts b/packages/cli/src/databases/sqlite/migrations/1620821879465-UniqueWorkflowNames.ts index 732b080ed0..6b50d94012 100644 --- a/packages/cli/src/databases/sqlite/migrations/1620821879465-UniqueWorkflowNames.ts +++ b/packages/cli/src/databases/sqlite/migrations/1620821879465-UniqueWorkflowNames.ts @@ -1,47 +1,64 @@ -import {MigrationInterface, QueryRunner} from "typeorm"; -import config = require("../../../../config"); +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config = require('../../../../config'); +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class UniqueWorkflowNames1620821879465 implements MigrationInterface { - name = 'UniqueWorkflowNames1620821879465'; + name = 'UniqueWorkflowNames1620821879465'; - async up(queryRunner: QueryRunner): Promise { - const tablePrefix = config.get('database.tablePrefix'); + async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); - const workflowNames = await queryRunner.query(` + const tablePrefix = config.get('database.tablePrefix'); + + const workflowNames = await queryRunner.query(` SELECT name FROM "${tablePrefix}workflow_entity" `); - for (const { name } of workflowNames) { - const [duplicatesQuery, parameters] = queryRunner.connection.driver.escapeQueryWithParameters(` + for (const { name } of workflowNames) { + const [duplicatesQuery, parameters] = queryRunner.connection.driver.escapeQueryWithParameters( + ` SELECT id, name FROM "${tablePrefix}workflow_entity" WHERE name = :name ORDER BY createdAt ASC - `, { name }, {}); + `, + { name }, + {}, + ); - const duplicates = await queryRunner.query(duplicatesQuery, parameters); + const duplicates = await queryRunner.query(duplicatesQuery, parameters); - if (duplicates.length > 1) { - await Promise.all(duplicates.map(({ id, name }: { id: number; name: string; }, index: number) => { + if (duplicates.length > 1) { + await Promise.all( + duplicates.map(({ id, name }: { id: number; name: string }, index: number) => { if (index === 0) return Promise.resolve(); - const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(` + const [updateQuery, updateParams] = + queryRunner.connection.driver.escapeQueryWithParameters( + ` UPDATE "${tablePrefix}workflow_entity" SET name = :name WHERE id = '${id}' - `, { name: `${name} ${index + 1}`}, {}); + `, + { name: `${name} ${index + 1}` }, + {}, + ); return queryRunner.query(updateQuery, updateParams); - })); - } + }), + ); } - - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_${tablePrefix}943d8f922be094eb507cb9a7f9" ON "${tablePrefix}workflow_entity" ("name") `); } - async down(queryRunner: QueryRunner): Promise { - const tablePrefix = config.get('database.tablePrefix'); - await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}943d8f922be094eb507cb9a7f9"`); - } + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_${tablePrefix}943d8f922be094eb507cb9a7f9" ON "${tablePrefix}workflow_entity" ("name") `, + ); + logMigrationEnd(this.name); + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}943d8f922be094eb507cb9a7f9"`); + } } diff --git a/packages/cli/src/databases/sqlite/migrations/1621707690587-AddWaitColumn.ts b/packages/cli/src/databases/sqlite/migrations/1621707690587-AddWaitColumn.ts index 950bef59f1..0c5c2b28c2 100644 --- a/packages/cli/src/databases/sqlite/migrations/1621707690587-AddWaitColumn.ts +++ b/packages/cli/src/databases/sqlite/migrations/1621707690587-AddWaitColumn.ts @@ -1,33 +1,55 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class AddWaitColumn1621707690587 implements MigrationInterface { name = 'AddWaitColumn1621707690587'; async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); - console.log('\n\nINFO: Started with migration for wait functionality.\n Depending on the number of saved executions, that may take a little bit.\n\n'); await queryRunner.query(`DROP TABLE IF EXISTS "${tablePrefix}temporary_execution_entity"`); - await queryRunner.query(`CREATE TABLE "${tablePrefix}temporary_execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime, "workflowData" text NOT NULL, "workflowId" varchar, "waitTill" DATETIME)`, undefined); - await queryRunner.query(`INSERT INTO "${tablePrefix}temporary_execution_entity"("id", "data", "finished", "mode", "retryOf", "retrySuccessId", "startedAt", "stoppedAt", "workflowData", "workflowId") SELECT "id", "data", "finished", "mode", "retryOf", "retrySuccessId", "startedAt", "stoppedAt", "workflowData", "workflowId" FROM "${tablePrefix}execution_entity"`); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}temporary_execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime, "workflowData" text NOT NULL, "workflowId" varchar, "waitTill" DATETIME)`, + undefined, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}temporary_execution_entity"("id", "data", "finished", "mode", "retryOf", "retrySuccessId", "startedAt", "stoppedAt", "workflowData", "workflowId") SELECT "id", "data", "finished", "mode", "retryOf", "retrySuccessId", "startedAt", "stoppedAt", "workflowData", "workflowId" FROM "${tablePrefix}execution_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}execution_entity"`); - await queryRunner.query(`ALTER TABLE "${tablePrefix}temporary_execution_entity" RENAME TO "${tablePrefix}execution_entity"`); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1" ON "${tablePrefix}execution_entity" ("stoppedAt")`); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2" ON "${tablePrefix}execution_entity" ("waitTill")`); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}temporary_execution_entity" RENAME TO "${tablePrefix}execution_entity"`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1" ON "${tablePrefix}execution_entity" ("stoppedAt")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2" ON "${tablePrefix}execution_entity" ("waitTill")`, + ); await queryRunner.query(`VACUUM;`); + + logMigrationEnd(this.name); } async down(queryRunner: QueryRunner): Promise { const tablePrefix = config.get('database.tablePrefix'); - await queryRunner.query(`CREATE TABLE IF NOT EXISTS "${tablePrefix}temporary_execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime, "workflowData" text NOT NULL, "workflowId" varchar)`, undefined); - await queryRunner.query(`INSERT INTO "${tablePrefix}temporary_execution_entity"("id", "data", "finished", "mode", "retryOf", "retrySuccessId", "startedAt", "stoppedAt", "workflowData", "workflowId") SELECT "id", "data", "finished", "mode", "retryOf", "retrySuccessId", "startedAt", "stoppedAt", "workflowData", "workflowId" FROM "${tablePrefix}execution_entity"`); + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "${tablePrefix}temporary_execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime, "workflowData" text NOT NULL, "workflowId" varchar)`, + undefined, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}temporary_execution_entity"("id", "data", "finished", "mode", "retryOf", "retrySuccessId", "startedAt", "stoppedAt", "workflowData", "workflowId") SELECT "id", "data", "finished", "mode", "retryOf", "retrySuccessId", "startedAt", "stoppedAt", "workflowData", "workflowId" FROM "${tablePrefix}execution_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}execution_entity"`); - await queryRunner.query(`ALTER TABLE "${tablePrefix}temporary_execution_entity" RENAME TO "${tablePrefix}execution_entity"`); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1" ON "${tablePrefix}execution_entity" ("stoppedAt")`); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}temporary_execution_entity" RENAME TO "${tablePrefix}execution_entity"`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1" ON "${tablePrefix}execution_entity" ("stoppedAt")`, + ); await queryRunner.query(`VACUUM;`); - } - } diff --git a/packages/cli/src/databases/sqlite/migrations/1630330987096-UpdateWorkflowCredentials.ts b/packages/cli/src/databases/sqlite/migrations/1630330987096-UpdateWorkflowCredentials.ts index 273f644e4e..9c9452f695 100644 --- a/packages/cli/src/databases/sqlite/migrations/1630330987096-UpdateWorkflowCredentials.ts +++ b/packages/cli/src/databases/sqlite/migrations/1630330987096-UpdateWorkflowCredentials.ts @@ -1,6 +1,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import config = require('../../../../config'); import { MigrationHelpers } from '../../MigrationHelpers'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; // replacing the credentials in workflows and execution // `nodeType: name` changes to `nodeType: { id, name }` @@ -9,8 +10,8 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac name = 'UpdateWorkflowCredentials1630330987096'; public async up(queryRunner: QueryRunner): Promise { - console.log('Start migration', this.name); - console.time(this.name); + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); const helpers = new MigrationHelpers(queryRunner); @@ -146,7 +147,8 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac queryRunner.query(updateQuery, updateParams); } }); - console.timeEnd(this.name); + + logMigrationEnd(this.name); } public async down(queryRunner: QueryRunner): Promise { diff --git a/packages/cli/src/databases/sqlite/migrations/1644421939510-AddExecutionEntityIndexes.ts b/packages/cli/src/databases/sqlite/migrations/1644421939510-AddExecutionEntityIndexes.ts index 1f6695626f..74f6822a93 100644 --- a/packages/cli/src/databases/sqlite/migrations/1644421939510-AddExecutionEntityIndexes.ts +++ b/packages/cli/src/databases/sqlite/migrations/1644421939510-AddExecutionEntityIndexes.ts @@ -1,31 +1,49 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class AddExecutionEntityIndexes1644421939510 implements MigrationInterface { - name = 'AddExecutionEntityIndexes1644421939510' + name = 'AddExecutionEntityIndexes1644421939510'; - public async up(queryRunner: QueryRunner): Promise { - console.log('\n\nINFO: Started migration for execution entity indexes.\n Depending on the number of saved executions, it may take a while.\n\n'); + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); - const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}c4d999a5e90784e8caccf5589d'`); - await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2'`); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}06da892aaf92a48e7d3e400003' ON '${tablePrefix}execution_entity' ('workflowId', 'waitTill', 'id') `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}78d62b89dc1433192b86dce18a' ON '${tablePrefix}execution_entity' ('workflowId', 'finished', 'id') `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}1688846335d274033e15c846a4' ON '${tablePrefix}execution_entity' ('finished', 'id') `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}b94b45ce2c73ce46c54f20b5f9' ON '${tablePrefix}execution_entity' ('waitTill', 'id') `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}81fc04c8a17de15835713505e4' ON '${tablePrefix}execution_entity' ('workflowId', 'id') `); - } + await queryRunner.query(`DROP INDEX 'IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2'`); - public async down(queryRunner: QueryRunner): Promise { - const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query( + `CREATE INDEX 'IDX_${tablePrefix}06da892aaf92a48e7d3e400003' ON '${tablePrefix}execution_entity' ('workflowId', 'waitTill', 'id') `, + ); + await queryRunner.query( + `CREATE INDEX 'IDX_${tablePrefix}78d62b89dc1433192b86dce18a' ON '${tablePrefix}execution_entity' ('workflowId', 'finished', 'id') `, + ); + await queryRunner.query( + `CREATE INDEX 'IDX_${tablePrefix}1688846335d274033e15c846a4' ON '${tablePrefix}execution_entity' ('finished', 'id') `, + ); + await queryRunner.query( + `CREATE INDEX 'IDX_${tablePrefix}b94b45ce2c73ce46c54f20b5f9' ON '${tablePrefix}execution_entity' ('waitTill', 'id') `, + ); + await queryRunner.query( + `CREATE INDEX 'IDX_${tablePrefix}81fc04c8a17de15835713505e4' ON '${tablePrefix}execution_entity' ('workflowId', 'id') `, + ); + logMigrationEnd(this.name); + } - await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}81fc04c8a17de15835713505e4'`); - await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}b94b45ce2c73ce46c54f20b5f9'`); - await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}1688846335d274033e15c846a4'`); - await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}78d62b89dc1433192b86dce18a'`); - await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}06da892aaf92a48e7d3e400003'`); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2' ON '${tablePrefix}execution_entity' ('waitTill') `); - } + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query(`DROP INDEX 'IDX_${tablePrefix}81fc04c8a17de15835713505e4'`); + await queryRunner.query(`DROP INDEX 'IDX_${tablePrefix}b94b45ce2c73ce46c54f20b5f9'`); + await queryRunner.query(`DROP INDEX 'IDX_${tablePrefix}1688846335d274033e15c846a4'`); + await queryRunner.query(`DROP INDEX 'IDX_${tablePrefix}78d62b89dc1433192b86dce18a'`); + await queryRunner.query(`DROP INDEX 'IDX_${tablePrefix}06da892aaf92a48e7d3e400003'`); + await queryRunner.query( + `CREATE INDEX 'IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2' ON '${tablePrefix}execution_entity' ('waitTill') `, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}c4d999a5e90784e8caccf5589d' ON '${tablePrefix}execution_entity' ('workflowId') `, + ); + } } diff --git a/packages/cli/src/databases/sqlite/migrations/1646992772331-CreateUserManagement.ts b/packages/cli/src/databases/sqlite/migrations/1646992772331-CreateUserManagement.ts new file mode 100644 index 0000000000..d087335331 --- /dev/null +++ b/packages/cli/src/databases/sqlite/migrations/1646992772331-CreateUserManagement.ts @@ -0,0 +1,118 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { v4 as uuid } from 'uuid'; +import config = require('../../../../config'); +import { + loadSurveyFromDisk, + logMigrationEnd, + logMigrationStart, +} from '../../utils/migrationHelpers'; + +export class CreateUserManagement1646992772331 implements MigrationInterface { + name = 'CreateUserManagement1646992772331'; + + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query( + `CREATE TABLE "${tablePrefix}role" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(32) NOT NULL, "scope" varchar NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "UQ_${tablePrefix}5b49d0f504f7ef31045a1fb2eb8" UNIQUE ("scope", "name"))`, + ); + + await queryRunner.query( + `CREATE TABLE "${tablePrefix}user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_${tablePrefix}e12875dfb3b1d92d7d7c5377e2" ON "${tablePrefix}user" ("email")`, + ); + + await queryRunner.query( + `CREATE TABLE "${tablePrefix}shared_workflow" ("createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "roleId" integer NOT NULL, "userId" varchar NOT NULL, "workflowId" integer NOT NULL, CONSTRAINT "FK_${tablePrefix}3540da03964527aa24ae014b780" FOREIGN KEY ("roleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_${tablePrefix}82b2fd9ec4e3e24209af8160282" FOREIGN KEY ("userId") REFERENCES "${tablePrefix}user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_${tablePrefix}b83f8d2530884b66a9c848c8b88" FOREIGN KEY ("workflowId") REFERENCES "${tablePrefix}workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("userId", "workflowId"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}65a0933c0f19d278881653bf81d35064" ON "${tablePrefix}shared_workflow" ("workflowId")`, + ); + + await queryRunner.query( + `CREATE TABLE "${tablePrefix}shared_credentials" ("createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "roleId" integer NOT NULL, "userId" varchar NOT NULL, "credentialsId" integer NOT NULL, CONSTRAINT "FK_${tablePrefix}c68e056637562000b68f480815a" FOREIGN KEY ("roleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_${tablePrefix}484f0327e778648dd04f1d70493" FOREIGN KEY ("userId") REFERENCES "${tablePrefix}user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_${tablePrefix}68661def1d4bcf2451ac8dbd949" FOREIGN KEY ("credentialsId") REFERENCES "${tablePrefix}credentials_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("userId", "credentialsId"))`, + ); + + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}829d16efa0e265cb076d50eca8d21733" ON "${tablePrefix}shared_credentials" ("credentialsId")`, + ); + + await queryRunner.query( + `CREATE TABLE "${tablePrefix}settings" ("key" TEXT NOT NULL,"value" TEXT NOT NULL DEFAULT \'\',"loadOnStartup" boolean NOT NULL default false,PRIMARY KEY("key"))`, + ); + + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_${tablePrefix}943d8f922be094eb507cb9a7f9"`); + + // Insert initial roles + await queryRunner.query(` + INSERT INTO "${tablePrefix}role" (name, scope) + VALUES ("owner", "global"); + `); + + const instanceOwnerRole = await queryRunner.query('SELECT last_insert_rowid() as insertId'); + + await queryRunner.query(` + INSERT INTO "${tablePrefix}role" (name, scope) + VALUES ("member", "global"); + `); + + await queryRunner.query(` + INSERT INTO "${tablePrefix}role" (name, scope) + VALUES ("owner", "workflow"); + `); + + const workflowOwnerRole = await queryRunner.query('SELECT last_insert_rowid() as insertId'); + + await queryRunner.query(` + INSERT INTO "${tablePrefix}role" (name, scope) + VALUES ("owner", "credential"); + `); + + const credentialOwnerRole = await queryRunner.query('SELECT last_insert_rowid() as insertId'); + + const survey = loadSurveyFromDisk(); + + const ownerUserId = uuid(); + await queryRunner.query( + ` + INSERT INTO "${tablePrefix}user" (id, globalRoleId, personalizationAnswers) values + (?, ?, ?) + `, + [ownerUserId, instanceOwnerRole[0].insertId, survey], + ); + + await queryRunner.query(` + INSERT INTO "${tablePrefix}shared_workflow" (createdAt, updatedAt, roleId, userId, workflowId) + select DATETIME('now'), DATETIME('now'), '${workflowOwnerRole[0].insertId}', '${ownerUserId}', id from "${tablePrefix}workflow_entity" + `); + + await queryRunner.query(` + INSERT INTO "${tablePrefix}shared_credentials" (createdAt, updatedAt, roleId, userId, credentialsId) + select DATETIME('now'), DATETIME('now'), '${credentialOwnerRole[0].insertId}', '${ownerUserId}', id from "${tablePrefix}credentials_entity" + `); + + await queryRunner.query(` + INSERT INTO "${tablePrefix}settings" (key, value, loadOnStartup) values + ('userManagement.isInstanceOwnerSetUp', 'false', true), ('userManagement.skipInstanceOwnerSetup', 'false', true) + `); + + logMigrationEnd(this.name); + } + + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_${tablePrefix}943d8f922be094eb507cb9a7f9" ON "${tablePrefix}workflow_entity" ("name") `, + ); + + await queryRunner.query(`DROP TABLE "${tablePrefix}shared_credentials"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}shared_workflow"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}user"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}role"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}settings"`); + } +} diff --git a/packages/cli/src/databases/sqlite/migrations/index.ts b/packages/cli/src/databases/sqlite/migrations/index.ts index 75ec0830e5..fe2a71b682 100644 --- a/packages/cli/src/databases/sqlite/migrations/index.ts +++ b/packages/cli/src/databases/sqlite/migrations/index.ts @@ -1,3 +1,5 @@ +import config = require('../../../../config'); + import { InitialMigration1588102412422 } from './1588102412422-InitialMigration'; import { WebhookModel1592445003908 } from './1592445003908-WebhookModel'; import { CreateIndexStoppedAt1594825041918 } from './1594825041918-CreateIndexStoppedAt'; @@ -8,8 +10,9 @@ import { UniqueWorkflowNames1620821879465 } from './1620821879465-UniqueWorkflow import { AddWaitColumn1621707690587 } from './1621707690587-AddWaitColumn'; import { UpdateWorkflowCredentials1630330987096 } from './1630330987096-UpdateWorkflowCredentials'; import { AddExecutionEntityIndexes1644421939510 } from './1644421939510-AddExecutionEntityIndexes'; +import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; -export const sqliteMigrations = [ +const sqliteMigrations = [ InitialMigration1588102412422, WebhookModel1592445003908, CreateIndexStoppedAt1594825041918, @@ -20,4 +23,7 @@ export const sqliteMigrations = [ AddWaitColumn1621707690587, UpdateWorkflowCredentials1630330987096, AddExecutionEntityIndexes1644421939510, + CreateUserManagement1646992772331, ]; + +export { sqliteMigrations }; diff --git a/packages/cli/src/databases/utils/customValidators.ts b/packages/cli/src/databases/utils/customValidators.ts new file mode 100644 index 0000000000..f84832fe55 --- /dev/null +++ b/packages/cli/src/databases/utils/customValidators.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { registerDecorator } from 'class-validator'; + +export function NoXss() { + return (object: object, propertyName: string): void => { + registerDecorator({ + name: 'NoXss', + target: object.constructor, + propertyName, + constraints: [propertyName], + options: { message: `Malicious ${propertyName}` }, + validator: { + validate(value: string) { + return !/<(\s*)?(script|a|http)/.test(value); + }, + }, + }); + }; +} diff --git a/packages/cli/src/databases/utils/migrationHelpers.ts b/packages/cli/src/databases/utils/migrationHelpers.ts new file mode 100644 index 0000000000..9acb0676f5 --- /dev/null +++ b/packages/cli/src/databases/utils/migrationHelpers.ts @@ -0,0 +1,55 @@ +import { readFileSync, rmSync } from 'fs'; +import { UserSettings } from 'n8n-core'; +import { getLogger } from '../../Logger'; + +const PERSONALIZATION_SURVEY_FILENAME = 'personalizationSurvey.json'; + +export function loadSurveyFromDisk(): string | null { + const userSettingsPath = UserSettings.getUserN8nFolderPath(); + try { + const filename = `${userSettingsPath}/${PERSONALIZATION_SURVEY_FILENAME}`; + const surveyFile = readFileSync(filename, 'utf-8'); + rmSync(filename); + const personalizationSurvey = JSON.parse(surveyFile) as object; + const kvPairs = Object.entries(personalizationSurvey); + if (!kvPairs.length) { + throw new Error('personalizationSurvey is empty'); + } else { + // eslint-disable-next-line @typescript-eslint/naming-convention + const emptyKeys = kvPairs.reduce((acc, [_key, value]) => { + if (!value || (Array.isArray(value) && !value.length)) { + return acc + 1; + } + return acc; + }, 0); + if (emptyKeys === kvPairs.length) { + throw new Error('incomplete personalizationSurvey'); + } + } + return surveyFile; + } catch (error) { + return null; + } +} + +let logFinishTimeout: NodeJS.Timeout; +const disableLogging = process.argv[1].split('/').includes('jest'); + +export function logMigrationStart(migrationName: string): void { + if (disableLogging) return; + const logger = getLogger(); + if (!logFinishTimeout) { + logger.warn('Migrations in progress, please do NOT stop the process.'); + } + logger.debug(`Starting migration ${migrationName}`); + clearTimeout(logFinishTimeout); +} + +export function logMigrationEnd(migrationName: string): void { + if (disableLogging) return; + const logger = getLogger(); + logger.debug(`Finished migration ${migrationName}`); + logFinishTimeout = setTimeout(() => { + logger.warn('Migrations finished.'); + }, 100); +} diff --git a/packages/cli/src/databases/utils/transformers.ts b/packages/cli/src/databases/utils/transformers.ts new file mode 100644 index 0000000000..f843c1c2f7 --- /dev/null +++ b/packages/cli/src/databases/utils/transformers.ts @@ -0,0 +1,20 @@ +// eslint-disable-next-line import/no-cycle +import { IPersonalizationSurveyAnswers } from '../../Interfaces'; + +export const idStringifier = { + from: (value: number): string | number => (value ? value.toString() : value), + to: (value: string): number | string => (value ? Number(value) : value), +}; + +/** + * Ensure a consistent return type for personalization answers in `User`. + * Answers currently stored as `TEXT` on Postgres. + */ +export const answersFormatter = { + to: (answers: IPersonalizationSurveyAnswers): IPersonalizationSurveyAnswers => answers, + from: (answers: IPersonalizationSurveyAnswers | string): IPersonalizationSurveyAnswers => { + return typeof answers === 'string' + ? (JSON.parse(answers) as IPersonalizationSurveyAnswers) + : answers; + }, +}; diff --git a/packages/cli/src/requests.d.ts b/packages/cli/src/requests.d.ts new file mode 100644 index 0000000000..72e1ffe0e2 --- /dev/null +++ b/packages/cli/src/requests.d.ts @@ -0,0 +1,272 @@ +/* eslint-disable import/no-cycle */ +import express = require('express'); +import { + IConnections, + ICredentialDataDecryptedObject, + ICredentialNodeAccess, + INode, + INodeCredentialTestRequest, + IRunData, + IWorkflowSettings, +} from 'n8n-workflow'; + +import { User } from './databases/entities/User'; +import type { IExecutionDeleteFilter, IWorkflowDb } from '.'; +import type { PublicUser } from './UserManagement/Interfaces'; + +export type AuthlessRequest< + RouteParams = {}, + ResponseBody = {}, + RequestBody = {}, + RequestQuery = {}, +> = express.Request; + +export type AuthenticatedRequest< + RouteParams = {}, + ResponseBody = {}, + RequestBody = {}, + RequestQuery = {}, +> = express.Request & { user: User }; + +// ---------------------------------- +// /workflows +// ---------------------------------- + +export declare namespace WorkflowRequest { + type RequestBody = Partial<{ + id: string; // delete if sent + name: string; + nodes: INode[]; + connections: IConnections; + settings: IWorkflowSettings; + active: boolean; + tags: string[]; + }>; + + type Create = AuthenticatedRequest<{}, {}, RequestBody>; + + type Get = AuthenticatedRequest<{ id: string }>; + + type Delete = Get; + + type Update = AuthenticatedRequest<{ id: string }, {}, RequestBody>; + + type NewName = express.Request<{}, {}, {}, { name?: string }>; + + type GetAll = AuthenticatedRequest<{}, {}, {}, { filter: string }>; + + type GetAllActive = AuthenticatedRequest; + + type GetAllActivationErrors = Get; + + type ManualRun = AuthenticatedRequest< + {}, + {}, + { + workflowData: IWorkflowDb; + runData: IRunData; + startNodes?: string[]; + destinationNode?: string; + } + >; +} + +// ---------------------------------- +// /credentials +// ---------------------------------- + +export declare namespace CredentialRequest { + type RequestBody = Partial<{ + id: string; // delete if sent + name: string; + type: string; + nodesAccess: ICredentialNodeAccess[]; + data: ICredentialDataDecryptedObject; + }>; + + type Create = AuthenticatedRequest<{}, {}, RequestBody>; + + type Get = AuthenticatedRequest<{ id: string }, {}, {}, Record>; + + type Delete = Get; + + type GetAll = AuthenticatedRequest<{}, {}, {}, { filter: string }>; + + type Update = AuthenticatedRequest<{ id: string }, {}, RequestBody>; + + type NewName = WorkflowRequest.NewName; + + type Test = AuthenticatedRequest<{}, {}, INodeCredentialTestRequest>; +} + +// ---------------------------------- +// /executions +// ---------------------------------- + +export declare namespace ExecutionRequest { + namespace QueryParam { + type GetAll = { + filter: string; // '{ waitTill: string; finished: boolean, [other: string]: string }' + limit: string; + lastId: string; + firstId: string; + }; + + type GetAllCurrent = { + filter: string; // '{ workflowId: string }' + }; + } + + type GetAll = AuthenticatedRequest<{}, {}, {}, QueryParam.GetAll>; + type Get = AuthenticatedRequest<{ id: string }, {}, {}, { unflattedResponse: 'true' | 'false' }>; + type Delete = AuthenticatedRequest<{}, {}, IExecutionDeleteFilter>; + type Retry = AuthenticatedRequest<{ id: string }, {}, { loadWorkflow: boolean }, {}>; + type Stop = AuthenticatedRequest<{ id: string }>; + type GetAllCurrent = AuthenticatedRequest<{}, {}, {}, QueryParam.GetAllCurrent>; +} + +// ---------------------------------- +// /me +// ---------------------------------- + +export declare namespace MeRequest { + export type Settings = AuthenticatedRequest< + {}, + {}, + Pick + >; + export type Password = AuthenticatedRequest< + {}, + {}, + { currentPassword: string; newPassword: string } + >; + export type SurveyAnswers = AuthenticatedRequest<{}, {}, Record | {}>; +} + +// ---------------------------------- +// /owner +// ---------------------------------- + +export declare namespace OwnerRequest { + type Post = AuthenticatedRequest< + {}, + {}, + Partial<{ + email: string; + password: string; + firstName: string; + lastName: string; + }>, + {} + >; +} + +// ---------------------------------- +// password reset endpoints +// ---------------------------------- + +export declare namespace PasswordResetRequest { + export type Email = AuthlessRequest<{}, {}, Pick>; + + export type Credentials = AuthlessRequest<{}, {}, {}, { userId?: string; token?: string }>; + + export type NewPassword = AuthlessRequest< + {}, + {}, + Pick & { token?: string; userId?: string } + >; +} + +// ---------------------------------- +// /users +// ---------------------------------- + +export declare namespace UserRequest { + export type Invite = AuthenticatedRequest<{}, {}, Array<{ email: string }>>; + + export type ResolveSignUp = AuthlessRequest< + {}, + {}, + {}, + { inviterId?: string; inviteeId?: string } + >; + + export type SignUp = AuthenticatedRequest< + { id: string }, + { inviterId?: string; inviteeId?: string } + >; + + export type Delete = AuthenticatedRequest<{ id: string }, {}, {}, { transferId?: string }>; + + export type Reinvite = AuthenticatedRequest<{ id: string }>; + + export type Update = AuthlessRequest< + { id: string }, + {}, + { + inviterId: string; + firstName: string; + lastName: string; + password: string; + } + >; +} + +// ---------------------------------- +// /login +// ---------------------------------- + +export type LoginRequest = AuthlessRequest< + {}, + {}, + { + email: string; + password: string; + } +>; + +// ---------------------------------- +// oauth endpoints +// ---------------------------------- + +export declare namespace OAuthRequest { + namespace OAuth1Credential { + type Auth = AuthenticatedRequest<{}, {}, {}, { id: string }>; + type Callback = AuthenticatedRequest< + {}, + {}, + {}, + { oauth_verifier: string; oauth_token: string; cid: string } + >; + } + + namespace OAuth2Credential { + type Auth = OAuth1Credential.Auth; + type Callback = AuthenticatedRequest<{}, {}, {}, { code: string; state: string }>; + } +} + +// ---------------------------------- +// /node-parameter-options +// ---------------------------------- + +export type NodeParameterOptionsRequest = AuthenticatedRequest< + {}, + {}, + {}, + { + nodeTypeAndVersion: string; + methodName: string; + path: string; + currentNodeParameters: string; + credentials: string; + } +>; + +// ---------------------------------- +// /tags +// ---------------------------------- + +export declare namespace TagsRequest { + type Delete = AuthenticatedRequest<{ id: string }>; +} diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 6b678f099f..69a4bad456 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import TelemetryClient = require('@rudderstack/rudder-sdk-node'); @@ -184,12 +185,17 @@ export class Telemetry { }); } - async track(eventName: string, properties?: IDataObject): Promise { + async track( + eventName: string, + properties: { [key: string]: unknown; user_id?: string } = {}, + ): Promise { return new Promise((resolve) => { if (this.client) { + const { user_id } = properties; + Object.assign(properties, { instance_id: this.instanceId }); this.client.track( { - userId: this.instanceId, + userId: `${this.instanceId}${user_id ? `#${user_id}` : ''}`, anonymousId: '000000000000', event: eventName, properties, diff --git a/packages/cli/test/integration/auth.endpoints.test.ts b/packages/cli/test/integration/auth.endpoints.test.ts new file mode 100644 index 0000000000..23ef7e3e3d --- /dev/null +++ b/packages/cli/test/integration/auth.endpoints.test.ts @@ -0,0 +1,147 @@ +import { hashSync, genSaltSync } from 'bcryptjs'; +import express = require('express'); +import validator from 'validator'; +import { v4 as uuid } from 'uuid'; + +import config = require('../../config'); +import * as utils from './shared/utils'; +import { LOGGED_OUT_RESPONSE_BODY } from './shared/constants'; +import { Db } from '../../src'; +import { Role } from '../../src/databases/entities/Role'; +import { randomEmail, randomValidPassword, randomName } from './shared/random'; +import { getGlobalOwnerRole } from './shared/testDb'; +import * as testDb from './shared/testDb'; + +let globalOwnerRole: Role; + +let app: express.Application; +let testDbName = ''; + +beforeAll(async () => { + app = utils.initTestServer({ endpointGroups: ['auth'], applyAuth: true }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + await testDb.truncate(['User'], testDbName); + + globalOwnerRole = await getGlobalOwnerRole(); + utils.initTestLogger(); + utils.initTestTelemetry(); +}); + +beforeEach(async () => { + await testDb.createUser({ + id: uuid(), + email: TEST_USER.email, + firstName: TEST_USER.firstName, + lastName: TEST_USER.lastName, + password: hashSync(TEST_USER.password, genSaltSync(10)), + globalRole: globalOwnerRole, + }); + + config.set('userManagement.isInstanceOwnerSetUp', true); + + await Db.collections.Settings!.update( + { key: 'userManagement.isInstanceOwnerSetUp' }, + { value: JSON.stringify(true) }, + ); +}); + +afterEach(async () => { + await testDb.truncate(['User'], testDbName); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +test('POST /login should log user in', async () => { + const authlessAgent = utils.createAgent(app); + + const response = await authlessAgent.post('/login').send({ + email: TEST_USER.email, + password: TEST_USER.password, + }); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + password, + personalizationAnswers, + globalRole, + resetPasswordToken, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(TEST_USER.email); + expect(firstName).toBe(TEST_USER.firstName); + expect(lastName).toBe(TEST_USER.lastName); + expect(password).toBeUndefined(); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(globalRole).toBeDefined(); + expect(globalRole.name).toBe('owner'); + expect(globalRole.scope).toBe('global'); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeDefined(); +}); + +test('GET /login should receive logged in user', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.get('/login'); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + password, + personalizationAnswers, + globalRole, + resetPasswordToken, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(TEST_USER.email); + expect(firstName).toBe(TEST_USER.firstName); + expect(lastName).toBe(TEST_USER.lastName); + expect(password).toBeUndefined(); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(globalRole).toBeDefined(); + expect(globalRole.name).toBe('owner'); + expect(globalRole.scope).toBe('global'); + + expect(response.headers['set-cookie']).toBeUndefined(); +}); + +test('POST /logout should log user out', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.post('/logout'); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(LOGGED_OUT_RESPONSE_BODY); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeUndefined(); +}); + +const TEST_USER = { + email: randomEmail(), + password: randomValidPassword(), + firstName: randomName(), + lastName: randomName(), +}; diff --git a/packages/cli/test/integration/auth.middleware.test.ts b/packages/cli/test/integration/auth.middleware.test.ts new file mode 100644 index 0000000000..d686496aab --- /dev/null +++ b/packages/cli/test/integration/auth.middleware.test.ts @@ -0,0 +1,59 @@ +import express = require('express'); +import * as request from 'supertest'; +import { + REST_PATH_SEGMENT, + ROUTES_REQUIRING_AUTHORIZATION, + ROUTES_REQUIRING_AUTHENTICATION, +} from './shared/constants'; + +import * as utils from './shared/utils'; +import * as testDb from './shared/testDb'; + +let app: express.Application; +let testDbName = ''; + +beforeAll(async () => { + app = utils.initTestServer({ + applyAuth: true, + endpointGroups: ['me', 'auth', 'owner', 'users'], + }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + utils.initTestLogger(); + utils.initTestTelemetry(); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +ROUTES_REQUIRING_AUTHENTICATION.concat(ROUTES_REQUIRING_AUTHORIZATION).forEach((route) => { + const [method, endpoint] = getMethodAndEndpoint(route); + + test(`${route} should return 401 Unauthorized if no cookie`, async () => { + const response = await request(app)[method](endpoint).use(utils.prefix(REST_PATH_SEGMENT)); + + expect(response.statusCode).toBe(401); + }); +}); + +ROUTES_REQUIRING_AUTHORIZATION.forEach(async (route) => { + const [method, endpoint] = getMethodAndEndpoint(route); + + test(`${route} should return 403 Forbidden for member`, async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const response = await authMemberAgent[method](endpoint); + if (response.statusCode === 500) { + console.log(response); + } + + expect(response.statusCode).toBe(403); + }); +}); + +function getMethodAndEndpoint(route: string) { + return route.split(' ').map((segment, index) => { + return index % 2 === 0 ? segment.toLowerCase() : segment; + }); +} diff --git a/packages/cli/test/integration/credentials.api.test.ts b/packages/cli/test/integration/credentials.api.test.ts new file mode 100644 index 0000000000..b05276c202 --- /dev/null +++ b/packages/cli/test/integration/credentials.api.test.ts @@ -0,0 +1,551 @@ +import express = require('express'); +import { UserSettings } from 'n8n-core'; +import { Db } from '../../src'; +import { randomName, randomString } from './shared/random'; +import * as utils from './shared/utils'; +import type { CredentialPayload, SaveCredentialFunction } from './shared/types'; +import { Role } from '../../src/databases/entities/Role'; +import { User } from '../../src/databases/entities/User'; +import * as testDb from './shared/testDb'; + +let app: express.Application; +let testDbName = ''; +let saveCredential: SaveCredentialFunction; + +beforeAll(async () => { + app = utils.initTestServer({ + endpointGroups: ['credentials'], + applyAuth: true, + }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + utils.initConfigFile(); + + const credentialOwnerRole = await testDb.getCredentialOwnerRole(); + saveCredential = affixRoleToSaveCredential(credentialOwnerRole); + utils.initTestTelemetry(); +}); + +beforeEach(async () => { + await testDb.createOwnerShell(); +}); + +afterEach(async () => { + // do not combine calls - shared table must be cleared first and separately + await testDb.truncate(['SharedCredentials'], testDbName); + await testDb.truncate(['User', 'Credentials'], testDbName); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +test('POST /credentials should create cred', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const payload = credentialPayload(); + + const response = await authOwnerAgent.post('/credentials').send(payload); + + expect(response.statusCode).toBe(200); + + const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + + expect(name).toBe(payload.name); + expect(type).toBe(payload.type); + expect(nodesAccess[0].nodeType).toBe(payload.nodesAccess[0].nodeType); + expect(encryptedData).not.toBe(payload.data); + + const credential = await Db.collections.Credentials!.findOneOrFail(id); + + expect(credential.name).toBe(payload.name); + expect(credential.type).toBe(payload.type); + expect(credential.nodesAccess[0].nodeType).toBe(payload.nodesAccess[0].nodeType); + expect(credential.data).not.toBe(payload.data); + + const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({ + relations: ['user', 'credentials'], + where: { credentials: credential }, + }); + + expect(sharedCredential.user.id).toBe(owner.id); + expect(sharedCredential.credentials.name).toBe(payload.name); +}); + +test('POST /credentials should fail with invalid inputs', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + for (const invalidPayload of INVALID_PAYLOADS) { + const response = await authOwnerAgent.post('/credentials').send(invalidPayload); + expect(response.statusCode).toBe(400); + } +}); + +test('POST /credentials should fail with missing encryption key', async () => { + const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); + mock.mockResolvedValue(undefined); + + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.post('/credentials').send(credentialPayload()); + + expect(response.statusCode).toBe(500); + + mock.mockRestore(); +}); + +test('POST /credentials should ignore ID in payload', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const firstResponse = await authOwnerAgent + .post('/credentials') + .send({ id: '8', ...credentialPayload() }); + + expect(firstResponse.body.data.id).not.toBe('8'); + + const secondResponse = await authOwnerAgent + .post('/credentials') + .send({ id: 8, ...credentialPayload() }); + + expect(secondResponse.body.data.id).not.toBe(8); +}); + +test('DELETE /credentials/:id should delete owned cred for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ data: true }); + + const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id); + + expect(deletedCredential).toBeUndefined(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne(); + + expect(deletedSharedCredential).toBeUndefined(); // deleted +}); + +test('DELETE /credentials/:id should delete non-owned cred for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const member = await testDb.createUser(); + const savedCredential = await saveCredential(credentialPayload(), { user: member }); + + const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ data: true }); + + const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id); + + expect(deletedCredential).toBeUndefined(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne(); + + expect(deletedSharedCredential).toBeUndefined(); // deleted +}); + +test('DELETE /credentials/:id should delete owned cred for member', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: member }); + + const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ data: true }); + + const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id); + + expect(deletedCredential).toBeUndefined(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne(); + + expect(deletedSharedCredential).toBeUndefined(); // deleted +}); + +test('DELETE /credentials/:id should not delete non-owned cred for member', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(404); + + const shellCredential = await Db.collections.Credentials!.findOne(savedCredential.id); + + expect(shellCredential).toBeDefined(); // not deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne(); + + expect(deletedSharedCredential).toBeDefined(); // not deleted +}); + +test('DELETE /credentials/:id should fail if cred not found', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.delete('/credentials/123'); + + expect(response.statusCode).toBe(404); +}); + +test('PATCH /credentials/:id should update owned cred for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + const patchPayload = credentialPayload(); + + const response = await authOwnerAgent + .patch(`/credentials/${savedCredential.id}`) + .send(patchPayload); + + expect(response.statusCode).toBe(200); + + const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + + expect(name).toBe(patchPayload.name); + expect(type).toBe(patchPayload.type); + expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(encryptedData).not.toBe(patchPayload.data); + + const credential = await Db.collections.Credentials!.findOneOrFail(id); + + expect(credential.name).toBe(patchPayload.name); + expect(credential.type).toBe(patchPayload.type); + expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(credential.data).not.toBe(patchPayload.data); + + const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({ + relations: ['credentials'], + where: { credentials: credential }, + }); + + expect(sharedCredential.credentials.name).toBe(patchPayload.name); // updated +}); + +test('PATCH /credentials/:id should update non-owned cred for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const member = await testDb.createUser(); + const savedCredential = await saveCredential(credentialPayload(), { user: member }); + const patchPayload = credentialPayload(); + + const response = await authOwnerAgent + .patch(`/credentials/${savedCredential.id}`) + .send(patchPayload); + + expect(response.statusCode).toBe(200); + + const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + + expect(name).toBe(patchPayload.name); + expect(type).toBe(patchPayload.type); + expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(encryptedData).not.toBe(patchPayload.data); + + const credential = await Db.collections.Credentials!.findOneOrFail(id); + + expect(credential.name).toBe(patchPayload.name); + expect(credential.type).toBe(patchPayload.type); + expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(credential.data).not.toBe(patchPayload.data); + + const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({ + relations: ['credentials'], + where: { credentials: credential }, + }); + + expect(sharedCredential.credentials.name).toBe(patchPayload.name); // updated +}); + +test('PATCH /credentials/:id should update owned cred for member', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: member }); + const patchPayload = credentialPayload(); + + const response = await authMemberAgent + .patch(`/credentials/${savedCredential.id}`) + .send(patchPayload); + + expect(response.statusCode).toBe(200); + + const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + + expect(name).toBe(patchPayload.name); + expect(type).toBe(patchPayload.type); + expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(encryptedData).not.toBe(patchPayload.data); + + const credential = await Db.collections.Credentials!.findOneOrFail(id); + + expect(credential.name).toBe(patchPayload.name); + expect(credential.type).toBe(patchPayload.type); + expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(credential.data).not.toBe(patchPayload.data); + + const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({ + relations: ['credentials'], + where: { credentials: credential }, + }); + + expect(sharedCredential.credentials.name).toBe(patchPayload.name); // updated +}); + +test('PATCH /credentials/:id should not update non-owned cred for member', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + const patchPayload = credentialPayload(); + + const response = await authMemberAgent + .patch(`/credentials/${savedCredential.id}`) + .send(patchPayload); + + expect(response.statusCode).toBe(404); + + const shellCredential = await Db.collections.Credentials!.findOneOrFail(savedCredential.id); + + expect(shellCredential.name).not.toBe(patchPayload.name); // not updated +}); + +test('PATCH /credentials/:id should fail with invalid inputs', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + for (const invalidPayload of INVALID_PAYLOADS) { + const response = await authOwnerAgent + .patch(`/credentials/${savedCredential.id}`) + .send(invalidPayload); + + expect(response.statusCode).toBe(400); + } +}); + +test('PATCH /credentials/:id should fail if cred not found', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.patch('/credentials/123').send(credentialPayload()); + + expect(response.statusCode).toBe(404); +}); + +test('PATCH /credentials/:id should fail with missing encryption key', async () => { + const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); + mock.mockResolvedValue(undefined); + + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.post('/credentials').send(credentialPayload()); + + expect(response.statusCode).toBe(500); + + mock.mockRestore(); +}); + +test('GET /credentials should retrieve all creds for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + for (let i = 0; i < 3; i++) { + await saveCredential(credentialPayload(), { user: owner }); + } + + const member = await testDb.createUser(); + + await saveCredential(credentialPayload(), { user: member }); + + const response = await authOwnerAgent.get('/credentials'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(4); // 3 owner + 1 member + + for (const credential of response.body.data) { + const { name, type, nodesAccess, data: encryptedData } = credential; + + expect(typeof name).toBe('string'); + expect(typeof type).toBe('string'); + expect(typeof nodesAccess[0].nodeType).toBe('string'); + expect(encryptedData).toBeUndefined(); + } +}); + +test('GET /credentials should retrieve owned creds for member', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + for (let i = 0; i < 3; i++) { + await saveCredential(credentialPayload(), { user: member }); + } + + const response = await authMemberAgent.get('/credentials'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(3); + + for (const credential of response.body.data) { + const { name, type, nodesAccess, data: encryptedData } = credential; + + expect(typeof name).toBe('string'); + expect(typeof type).toBe('string'); + expect(typeof nodesAccess[0].nodeType).toBe('string'); + expect(encryptedData).toBeUndefined(); + } +}); + +test('GET /credentials should not retrieve non-owned creds for member', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + for (let i = 0; i < 3; i++) { + await saveCredential(credentialPayload(), { user: owner }); + } + + const response = await authMemberAgent.get('/credentials'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(0); // owner's creds not returned +}); + +test('GET /credentials/:id should retrieve owned cred for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + const firstResponse = await authOwnerAgent.get(`/credentials/${savedCredential.id}`); + + expect(firstResponse.statusCode).toBe(200); + + expect(typeof firstResponse.body.data.name).toBe('string'); + expect(typeof firstResponse.body.data.type).toBe('string'); + expect(typeof firstResponse.body.data.nodesAccess[0].nodeType).toBe('string'); + expect(firstResponse.body.data.data).toBeUndefined(); + + const secondResponse = await authOwnerAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); + + expect(secondResponse.statusCode).toBe(200); + expect(typeof secondResponse.body.data.name).toBe('string'); + expect(typeof secondResponse.body.data.type).toBe('string'); + expect(typeof secondResponse.body.data.nodesAccess[0].nodeType).toBe('string'); + expect(secondResponse.body.data.data).toBeDefined(); +}); + +test('GET /credentials/:id should retrieve owned cred for member', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: member }); + + const firstResponse = await authMemberAgent.get(`/credentials/${savedCredential.id}`); + + expect(firstResponse.statusCode).toBe(200); + + expect(typeof firstResponse.body.data.name).toBe('string'); + expect(typeof firstResponse.body.data.type).toBe('string'); + expect(typeof firstResponse.body.data.nodesAccess[0].nodeType).toBe('string'); + expect(firstResponse.body.data.data).toBeUndefined(); + + const secondResponse = await authMemberAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); + + expect(secondResponse.statusCode).toBe(200); + + expect(typeof secondResponse.body.data.name).toBe('string'); + expect(typeof secondResponse.body.data.type).toBe('string'); + expect(typeof secondResponse.body.data.nodesAccess[0].nodeType).toBe('string'); + expect(secondResponse.body.data.data).toBeDefined(); +}); + +test('GET /credentials/:id should not retrieve non-owned cred for member', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + const response = await authMemberAgent.get(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(404); + expect(response.body.data).toBeUndefined(); // owner's cred not returned +}); + +test('GET /credentials/:id should fail with missing encryption key', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); + mock.mockResolvedValue(undefined); + + const response = await authOwnerAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); + + expect(response.statusCode).toBe(500); + + mock.mockRestore(); +}); + +test('GET /credentials/:id should return 404 if cred not found', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authMemberAgent.get('/credentials/789'); + + expect(response.statusCode).toBe(404); +}); + +const credentialPayload = () => ({ + name: randomName(), + type: randomName(), + nodesAccess: [{ nodeType: randomName() }], + data: { accessToken: randomString(6, 16) }, +}); + +const INVALID_PAYLOADS = [ + { + type: randomName(), + nodesAccess: [{ nodeType: randomName() }], + data: { accessToken: randomString(6, 16) }, + }, + { + name: randomName(), + nodesAccess: [{ nodeType: randomName() }], + data: { accessToken: randomString(6, 16) }, + }, + { + name: randomName(), + type: randomName(), + data: { accessToken: randomString(6, 16) }, + }, + { + name: randomName(), + type: randomName(), + nodesAccess: [{ nodeType: randomName() }], + }, + {}, + [], + undefined, +]; + +function affixRoleToSaveCredential(role: Role) { + return (credentialPayload: CredentialPayload, { user }: { user: User }) => + testDb.saveCredential(credentialPayload, { user, role }); +} diff --git a/packages/cli/test/integration/me.endpoints.test.ts b/packages/cli/test/integration/me.endpoints.test.ts new file mode 100644 index 0000000000..d8bbd66592 --- /dev/null +++ b/packages/cli/test/integration/me.endpoints.test.ts @@ -0,0 +1,529 @@ +import { hashSync, genSaltSync } from 'bcryptjs'; +import express = require('express'); +import validator from 'validator'; + +import config = require('../../config'); +import * as utils from './shared/utils'; +import { SUCCESS_RESPONSE_BODY } from './shared/constants'; +import { Db } from '../../src'; +import { Role } from '../../src/databases/entities/Role'; +import { randomValidPassword, randomEmail, randomName, randomString } from './shared/random'; +import * as testDb from './shared/testDb'; + +let app: express.Application; +let testDbName = ''; +let globalOwnerRole: Role; + +beforeAll(async () => { + app = utils.initTestServer({ endpointGroups: ['me'], applyAuth: true }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + globalOwnerRole = await testDb.getGlobalOwnerRole(); + utils.initTestLogger(); + utils.initTestTelemetry(); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +describe('Owner shell', () => { + beforeEach(async () => { + await testDb.createOwnerShell(); + }); + + afterEach(async () => { + await testDb.truncate(['User'], testDbName); + }); + + test('GET /me should return sanitized owner shell', async () => { + const ownerShell = await Db.collections.User!.findOneOrFail(); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const response = await authOwnerShellAgent.get('/me'); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBeNull(); + expect(firstName).toBeNull(); + expect(lastName).toBeNull(); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(isPending).toBe(true); + expect(globalRole.name).toBe('owner'); + expect(globalRole.scope).toBe('global'); + }); + + test('PATCH /me should succeed with valid inputs', async () => { + const ownerShell = await Db.collections.User!.findOneOrFail(); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + for (const validPayload of VALID_PATCH_ME_PAYLOADS) { + const response = await authOwnerShellAgent.patch('/me').send(validPayload); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(validPayload.email); + expect(firstName).toBe(validPayload.firstName); + expect(lastName).toBe(validPayload.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(isPending).toBe(false); + expect(globalRole.name).toBe('owner'); + expect(globalRole.scope).toBe('global'); + + const storedOwnerShell = await Db.collections.User!.findOneOrFail(id); + + expect(storedOwnerShell.email).toBe(validPayload.email); + expect(storedOwnerShell.firstName).toBe(validPayload.firstName); + expect(storedOwnerShell.lastName).toBe(validPayload.lastName); + } + }); + + test('PATCH /me should fail with invalid inputs', async () => { + const ownerShell = await Db.collections.User!.findOneOrFail(); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + for (const invalidPayload of INVALID_PATCH_ME_PAYLOADS) { + const response = await authOwnerShellAgent.patch('/me').send(invalidPayload); + expect(response.statusCode).toBe(400); + + const storedOwnerShell = await Db.collections.User!.findOneOrFail(); + expect(storedOwnerShell.email).toBeNull(); + expect(storedOwnerShell.firstName).toBeNull(); + expect(storedOwnerShell.lastName).toBeNull(); + } + }); + + test('PATCH /me/password should fail for shell', async () => { + const ownerShell = await Db.collections.User!.findOneOrFail(); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const validPasswordPayload = { + currentPassword: randomValidPassword(), + newPassword: randomValidPassword(), + }; + + const payloads = [validPasswordPayload, ...INVALID_PASSWORD_PAYLOADS]; + + for (const payload of payloads) { + const response = await authOwnerShellAgent.patch('/me/password').send(payload); + expect([400, 500].includes(response.statusCode)).toBe(true); + + const storedMember = await Db.collections.User!.findOneOrFail(); + + if (payload.newPassword) { + expect(storedMember.password).not.toBe(payload.newPassword); + } + if (payload.currentPassword) { + expect(storedMember.password).not.toBe(payload.currentPassword); + } + } + + const storedOwnerShell = await Db.collections.User!.findOneOrFail(); + expect(storedOwnerShell.password).toBeNull(); + }); + + test('POST /me/survey should succeed with valid inputs', async () => { + const ownerShell = await Db.collections.User!.findOneOrFail(); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const validPayloads = [SURVEY, {}]; + + for (const validPayload of validPayloads) { + const response = await authOwnerShellAgent.post('/me/survey').send(validPayload); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); + + const { personalizationAnswers: storedAnswers } = await Db.collections.User!.findOneOrFail(); + + expect(storedAnswers).toEqual(validPayload); + } + }); +}); + +describe('Member', () => { + beforeEach(async () => { + config.set('userManagement.isInstanceOwnerSetUp', true); + + await Db.collections.Settings!.update( + { key: 'userManagement.isInstanceOwnerSetUp' }, + { value: JSON.stringify(true) }, + ); + }); + + afterEach(async () => { + await testDb.truncate(['User'], testDbName); + }); + + test('GET /me should return sanitized member', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + const response = await authMemberAgent.get('/me'); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(member.email); + expect(firstName).toBe(member.firstName); + expect(lastName).toBe(member.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(isPending).toBe(false); + expect(globalRole.name).toBe('member'); + expect(globalRole.scope).toBe('global'); + }); + + test('PATCH /me should succeed with valid inputs', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + for (const validPayload of VALID_PATCH_ME_PAYLOADS) { + const response = await authMemberAgent.patch('/me').send(validPayload); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(validPayload.email); + expect(firstName).toBe(validPayload.firstName); + expect(lastName).toBe(validPayload.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(isPending).toBe(false); + expect(globalRole.name).toBe('member'); + expect(globalRole.scope).toBe('global'); + + const storedMember = await Db.collections.User!.findOneOrFail(id); + + expect(storedMember.email).toBe(validPayload.email); + expect(storedMember.firstName).toBe(validPayload.firstName); + expect(storedMember.lastName).toBe(validPayload.lastName); + } + }); + + test('PATCH /me should fail with invalid inputs', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + for (const invalidPayload of INVALID_PATCH_ME_PAYLOADS) { + const response = await authMemberAgent.patch('/me').send(invalidPayload); + expect(response.statusCode).toBe(400); + + const storedMember = await Db.collections.User!.findOneOrFail(); + expect(storedMember.email).toBe(member.email); + expect(storedMember.firstName).toBe(member.firstName); + expect(storedMember.lastName).toBe(member.lastName); + } + }); + + test('PATCH /me/password should succeed with valid inputs', async () => { + const memberPassword = randomValidPassword(); + const member = await testDb.createUser({ + password: hashSync(memberPassword, genSaltSync(10)), + }); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + const validPayload = { + currentPassword: memberPassword, + newPassword: randomValidPassword(), + }; + + const response = await authMemberAgent.patch('/me/password').send(validPayload); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); + + const storedMember = await Db.collections.User!.findOneOrFail(); + expect(storedMember.password).not.toBe(member.password); + expect(storedMember.password).not.toBe(validPayload.newPassword); + }); + + test('PATCH /me/password should fail with invalid inputs', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + for (const payload of INVALID_PASSWORD_PAYLOADS) { + const response = await authMemberAgent.patch('/me/password').send(payload); + expect([400, 500].includes(response.statusCode)).toBe(true); + + const storedMember = await Db.collections.User!.findOneOrFail(); + + if (payload.newPassword) { + expect(storedMember.password).not.toBe(payload.newPassword); + } + if (payload.currentPassword) { + expect(storedMember.password).not.toBe(payload.currentPassword); + } + } + }); + + test('POST /me/survey should succeed with valid inputs', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + const validPayloads = [SURVEY, {}]; + + for (const validPayload of validPayloads) { + const response = await authMemberAgent.post('/me/survey').send(validPayload); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); + + const { personalizationAnswers: storedAnswers } = await Db.collections.User!.findOneOrFail(); + + expect(storedAnswers).toEqual(validPayload); + } + }); +}); + +describe('Owner', () => { + beforeEach(async () => { + config.set('userManagement.isInstanceOwnerSetUp', true); + }); + + afterEach(async () => { + await testDb.truncate(['User'], testDbName); + }); + + test('GET /me should return sanitized owner', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.get('/me'); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(owner.email); + expect(firstName).toBe(owner.firstName); + expect(lastName).toBe(owner.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(isPending).toBe(false); + expect(globalRole.name).toBe('owner'); + expect(globalRole.scope).toBe('global'); + }); + + test('PATCH /me should succeed with valid inputs', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + for (const validPayload of VALID_PATCH_ME_PAYLOADS) { + const response = await authOwnerAgent.patch('/me').send(validPayload); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(validPayload.email); + expect(firstName).toBe(validPayload.firstName); + expect(lastName).toBe(validPayload.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(isPending).toBe(false); + expect(globalRole.name).toBe('owner'); + expect(globalRole.scope).toBe('global'); + + const storedOwner = await Db.collections.User!.findOneOrFail(id); + + expect(storedOwner.email).toBe(validPayload.email); + expect(storedOwner.firstName).toBe(validPayload.firstName); + expect(storedOwner.lastName).toBe(validPayload.lastName); + } + }); +}); + +const TEST_USER = { + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), +}; + +const SURVEY = [ + 'codingSkill', + 'companyIndustry', + 'companySize', + 'otherCompanyIndustry', + 'otherWorkArea', + 'workArea', +].reduce>((acc, cur) => { + return (acc[cur] = randomString(2, 10)), acc; +}, {}); + +const VALID_PATCH_ME_PAYLOADS = [ + { + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), + }, + { + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), + }, +]; + +const INVALID_PATCH_ME_PAYLOADS = [ + { + email: 'invalid', + firstName: randomName(), + lastName: randomName(), + }, + { + email: randomEmail(), + firstName: '', + lastName: randomName(), + }, + { + email: randomEmail(), + firstName: randomName(), + lastName: '', + }, + { + email: randomEmail(), + firstName: 123, + lastName: randomName(), + }, + { + firstName: randomName(), + lastName: randomName(), + }, + { + firstName: randomName(), + }, + { + lastName: randomName(), + }, + { + email: randomEmail(), + firstName: 'John { + app = utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + utils.initTestLogger(); + utils.initTestTelemetry(); +}); + +beforeEach(async () => { + await testDb.createOwnerShell(); +}); + +afterEach(async () => { + await testDb.truncate(['User'], testDbName); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +test('POST /owner should create owner and enable isInstanceOwnerSetUp', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.post('/owner').send(TEST_USER); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(TEST_USER.email); + expect(firstName).toBe(TEST_USER.firstName); + expect(lastName).toBe(TEST_USER.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(isPending).toBe(false); + expect(resetPasswordToken).toBeUndefined(); + expect(globalRole.name).toBe('owner'); + expect(globalRole.scope).toBe('global'); + + const storedOwner = await Db.collections.User!.findOneOrFail(id); + expect(storedOwner.password).not.toBe(TEST_USER.password); + expect(storedOwner.email).toBe(TEST_USER.email); + expect(storedOwner.firstName).toBe(TEST_USER.firstName); + expect(storedOwner.lastName).toBe(TEST_USER.lastName); + + const isInstanceOwnerSetUpConfig = config.get('userManagement.isInstanceOwnerSetUp'); + expect(isInstanceOwnerSetUpConfig).toBe(true); + + const isInstanceOwnerSetUpSetting = await utils.isInstanceOwnerSetUp(); + expect(isInstanceOwnerSetUpSetting).toBe(true); +}); + +test('POST /owner should fail with invalid inputs', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + for (const invalidPayload of INVALID_POST_OWNER_PAYLOADS) { + const response = await authOwnerAgent.post('/owner').send(invalidPayload); + expect(response.statusCode).toBe(400); + } +}); + +test('POST /owner/skip-setup should persist skipping setup to the DB', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.post('/owner/skip-setup').send(); + + expect(response.statusCode).toBe(200); + + const skipConfig = config.get('userManagement.skipInstanceOwnerSetup'); + expect(skipConfig).toBe(true); + + const { value } = await Db.collections.Settings!.findOneOrFail({ + key: 'userManagement.skipInstanceOwnerSetup', + }); + expect(value).toBe('true'); +}); + +const TEST_USER = { + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), +}; + +const INVALID_POST_OWNER_PAYLOADS = [ + { + email: '', + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), + }, + { + email: randomEmail(), + firstName: '', + lastName: randomName(), + password: randomValidPassword(), + }, + { + email: randomEmail(), + firstName: randomName(), + lastName: '', + password: randomValidPassword(), + }, + { + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomInvalidPassword(), + }, + { + firstName: randomName(), + lastName: randomName(), + }, + { + firstName: randomName(), + }, + { + lastName: randomName(), + }, + { + email: randomEmail(), + firstName: 'John { + app = utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + await testDb.truncate(['User'], testDbName); + + globalOwnerRole = await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'global', + }); + + utils.initTestTelemetry(); + utils.initTestLogger(); +}); + +beforeEach(async () => { + jest.isolateModules(() => { + jest.mock('../../config'); + }); + + config.set('userManagement.isInstanceOwnerSetUp', true); + config.set('userManagement.emails.mode', ''); + + await testDb.createUser({ + id: INITIAL_TEST_USER.id, + email: INITIAL_TEST_USER.email, + password: INITIAL_TEST_USER.password, + firstName: INITIAL_TEST_USER.firstName, + lastName: INITIAL_TEST_USER.lastName, + globalRole: globalOwnerRole, + }); +}); + +afterEach(async () => { + await testDb.truncate(['User'], testDbName); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +test('POST /forgot-password should send password reset email', async () => { + const authlessAgent = utils.createAgent(app); + + const { + user, + pass, + smtp: { host, port, secure }, + } = await utils.getSmtpTestAccount(); + + config.set('userManagement.emails.mode', 'smtp'); + config.set('userManagement.emails.smtp.host', host); + config.set('userManagement.emails.smtp.port', port); + config.set('userManagement.emails.smtp.secure', secure); + config.set('userManagement.emails.smtp.auth.user', user); + config.set('userManagement.emails.smtp.auth.pass', pass); + + const response = await authlessAgent + .post('/forgot-password') + .send({ email: INITIAL_TEST_USER.email }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({}); + + const owner = await Db.collections.User!.findOneOrFail({ email: INITIAL_TEST_USER.email }); + expect(owner.resetPasswordToken).toBeDefined(); + expect(owner.resetPasswordTokenExpiration).toBeGreaterThan(Math.ceil(Date.now() / 1000)); +}); + +test('POST /forgot-password should fail if emailing is not set up', async () => { + const authlessAgent = utils.createAgent(app); + + const response = await authlessAgent + .post('/forgot-password') + .send({ email: INITIAL_TEST_USER.email }); + + expect(response.statusCode).toBe(500); + + const owner = await Db.collections.User!.findOneOrFail({ email: INITIAL_TEST_USER.email }); + expect(owner.resetPasswordToken).toBeNull(); +}); + +test('POST /forgot-password should fail with invalid inputs', async () => { + const authlessAgent = utils.createAgent(app); + + config.set('userManagement.emails.mode', 'smtp'); + + const invalidPayloads = [ + randomEmail(), + [randomEmail()], + {}, + [{ name: randomName() }], + [{ email: randomName() }], + ]; + + for (const invalidPayload of invalidPayloads) { + const response = await authlessAgent.post('/forgot-password').send(invalidPayload); + expect(response.statusCode).toBe(400); + + const owner = await Db.collections.User!.findOneOrFail({ email: INITIAL_TEST_USER.email }); + expect(owner.resetPasswordToken).toBeNull(); + } +}); + +test('POST /forgot-password should fail if user is not found', async () => { + const authlessAgent = utils.createAgent(app); + + config.set('userManagement.emails.mode', 'smtp'); + + const response = await authlessAgent.post('/forgot-password').send({ email: randomEmail() }); + + // response should have 200 to not provide any information to the requester + expect(response.statusCode).toBe(200); +}); + +test('GET /resolve-password-token should succeed with valid inputs', async () => { + const authlessAgent = utils.createAgent(app); + + const resetPasswordToken = uuid(); + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100; + + await Db.collections.User!.update(INITIAL_TEST_USER.id, { + resetPasswordToken, + resetPasswordTokenExpiration, + }); + + const response = await authlessAgent + .get('/resolve-password-token') + .query({ userId: INITIAL_TEST_USER.id, token: resetPasswordToken }); + + expect(response.statusCode).toBe(200); +}); + +test('GET /resolve-password-token should fail with invalid inputs', async () => { + const authlessAgent = utils.createAgent(app); + + config.set('userManagement.emails.mode', 'smtp'); + + const first = await authlessAgent.get('/resolve-password-token').query({ token: uuid() }); + + const second = await authlessAgent + .get('/resolve-password-token') + .query({ userId: INITIAL_TEST_USER.id }); + + for (const response of [first, second]) { + expect(response.statusCode).toBe(400); + } +}); + +test('GET /resolve-password-token should fail if user is not found', async () => { + const authlessAgent = utils.createAgent(app); + + config.set('userManagement.emails.mode', 'smtp'); + + const response = await authlessAgent + .get('/resolve-password-token') + .query({ userId: INITIAL_TEST_USER.id, token: uuid() }); + + expect(response.statusCode).toBe(404); +}); + +test('GET /resolve-password-token should fail if token is expired', async () => { + const authlessAgent = utils.createAgent(app); + + const resetPasswordToken = uuid(); + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) - 1; + + await Db.collections.User!.update(INITIAL_TEST_USER.id, { + resetPasswordToken, + resetPasswordTokenExpiration, + }); + + config.set('userManagement.emails.mode', 'smtp'); + + const response = await authlessAgent + .get('/resolve-password-token') + .query({ userId: INITIAL_TEST_USER.id, token: resetPasswordToken }); + + expect(response.statusCode).toBe(404); +}); + +test('POST /change-password should succeed with valid inputs', async () => { + const authlessAgent = utils.createAgent(app); + + const resetPasswordToken = uuid(); + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100; + + await Db.collections.User!.update(INITIAL_TEST_USER.id, { + resetPasswordToken, + resetPasswordTokenExpiration, + }); + + const passwordToStore = randomValidPassword(); + + const response = await authlessAgent.post('/change-password').send({ + token: resetPasswordToken, + userId: INITIAL_TEST_USER.id, + password: passwordToStore, + }); + + expect(response.statusCode).toBe(200); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeDefined(); + + const { password: storedPassword } = await Db.collections.User!.findOneOrFail( + INITIAL_TEST_USER.id, + ); + + const comparisonResult = await compare(passwordToStore, storedPassword!); + expect(comparisonResult).toBe(true); + expect(storedPassword).not.toBe(passwordToStore); +}); + +test('POST /change-password should fail with invalid inputs', async () => { + const authlessAgent = utils.createAgent(app); + + const resetPasswordToken = uuid(); + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100; + + await Db.collections.User!.update(INITIAL_TEST_USER.id, { + resetPasswordToken, + resetPasswordTokenExpiration, + }); + + const invalidPayloads = [ + { token: uuid() }, + { id: INITIAL_TEST_USER.id }, + { password: randomValidPassword() }, + { token: uuid(), id: INITIAL_TEST_USER.id }, + { token: uuid(), password: randomValidPassword() }, + { id: INITIAL_TEST_USER.id, password: randomValidPassword() }, + { + id: INITIAL_TEST_USER.id, + password: randomInvalidPassword(), + token: resetPasswordToken, + }, + { + id: INITIAL_TEST_USER.id, + password: randomValidPassword(), + token: uuid(), + }, + ]; + + const { password: originalHashedPassword } = await Db.collections.User!.findOneOrFail(); + + for (const invalidPayload of invalidPayloads) { + const response = await authlessAgent.post('/change-password').query(invalidPayload); + expect(response.statusCode).toBe(400); + + const { password: fetchedHashedPassword } = await Db.collections.User!.findOneOrFail(); + expect(originalHashedPassword).toBe(fetchedHashedPassword); + } +}); + +test('POST /change-password should fail when token has expired', async () => { + const authlessAgent = utils.createAgent(app); + + const resetPasswordToken = uuid(); + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) - 1; + + await Db.collections.User!.update(INITIAL_TEST_USER.id, { + resetPasswordToken, + resetPasswordTokenExpiration, + }); + + const passwordToStore = randomValidPassword(); + + const response = await authlessAgent.post('/change-password').send({ + token: resetPasswordToken, + userId: INITIAL_TEST_USER.id, + password: passwordToStore, + }); + + expect(response.statusCode).toBe(404); +}); + +const INITIAL_TEST_USER = { + id: uuid(), + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), +}; diff --git a/packages/cli/test/integration/shared/augmentation.d.ts b/packages/cli/test/integration/shared/augmentation.d.ts new file mode 100644 index 0000000000..43a74a2f34 --- /dev/null +++ b/packages/cli/test/integration/shared/augmentation.d.ts @@ -0,0 +1,21 @@ +import superagent = require('superagent'); +import { ObjectLiteral } from 'typeorm'; + +/** + * Make `SuperTest` string-indexable. + */ +declare module 'supertest' { + interface SuperTest + extends superagent.SuperAgent, + Record {} +} + +/** + * Prevent `repository.delete({})` (non-criteria) from triggering the type error + * `Expression produces a union type that is too complex to represent.ts(2590)` + */ +declare module 'typeorm' { + interface Repository { + delete(criteria: {}): Promise; + } +} diff --git a/packages/cli/test/integration/shared/constants.ts b/packages/cli/test/integration/shared/constants.ts new file mode 100644 index 0000000000..99c624efb4 --- /dev/null +++ b/packages/cli/test/integration/shared/constants.ts @@ -0,0 +1,59 @@ +import config = require('../../../config'); + +export const REST_PATH_SEGMENT = config.get('endpoints.rest') as Readonly; + +export const AUTHLESS_ENDPOINTS: Readonly = [ + 'healthz', + 'metrics', + config.get('endpoints.webhook') as string, + config.get('endpoints.webhookWaiting') as string, + config.get('endpoints.webhookTest') as string, +]; + +export const SUCCESS_RESPONSE_BODY = { + data: { + success: true, + }, +} as const; + +export const LOGGED_OUT_RESPONSE_BODY = { + data: { + loggedOut: true, + }, +}; + +/** + * Routes requiring a valid `n8n-auth` cookie for a user, either owner or member. + */ +export const ROUTES_REQUIRING_AUTHENTICATION: Readonly = [ + 'GET /me', + 'PATCH /me', + 'PATCH /me/password', + 'POST /me/survey', + 'POST /owner', + 'GET /non-existent', +]; + +/** + * Routes requiring a valid `n8n-auth` cookie for an owner. + */ +export const ROUTES_REQUIRING_AUTHORIZATION: Readonly = [ + 'POST /users', + 'GET /users', + 'DELETE /users/123', + 'POST /users/123/reinvite', + 'POST /owner', + 'POST /owner/skip-setup', +]; + +/** + * Name of the connection used for creating and dropping a Postgres DB + * for each suite test run. + */ +export const BOOTSTRAP_POSTGRES_CONNECTION_NAME: Readonly = 'n8n_bs_postgres'; + +/** + * Name of the connection (and database) used for creating and dropping a MySQL DB + * for each suite test run. + */ +export const BOOTSTRAP_MYSQL_CONNECTION_NAME: Readonly = 'n8n_bs_mysql'; diff --git a/packages/cli/test/integration/shared/random.ts b/packages/cli/test/integration/shared/random.ts new file mode 100644 index 0000000000..4a531aba3b --- /dev/null +++ b/packages/cli/test/integration/shared/random.ts @@ -0,0 +1,41 @@ +import { randomBytes } from 'crypto'; +import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '../../../src/databases/entities/User'; + +/** + * Create a random alphanumeric string of random length between two limits, both inclusive. + * Limits should be even numbers (round down otherwise). + */ +export function randomString(min: number, max: number) { + const randomInteger = Math.floor(Math.random() * (max - min) + min) + 1; + return randomBytes(randomInteger / 2).toString('hex'); +} + +const chooseRandomly = (array: T[]) => array[Math.floor(Math.random() * array.length)]; + +const randomDigit = () => Math.floor(Math.random() * 10); + +const randomUppercaseLetter = () => chooseRandomly('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')); + +export const randomValidPassword = () => + randomString(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH) + randomUppercaseLetter() + randomDigit(); + +export const randomInvalidPassword = () => + chooseRandomly([ + randomString(1, MIN_PASSWORD_LENGTH - 1), + randomString(MAX_PASSWORD_LENGTH + 2, MAX_PASSWORD_LENGTH + 100), + 'abcdefgh', // valid length, no number, no uppercase + 'abcdefg1', // valid length, has number, no uppercase + 'abcdefgA', // valid length, no number, has uppercase + 'abcdefA', // invalid length, no number, has uppercase + 'abcdef1', // invalid length, has number, no uppercase + 'abcdeA1', // invalid length, has number, has uppercase + 'abcdefg', // invalid length, no number, no uppercase + ]); + +export const randomEmail = () => `${randomName()}@${randomName()}.${randomTopLevelDomain()}`; + +const POPULAR_TOP_LEVEL_DOMAINS = ['com', 'org', 'net', 'io', 'edu']; + +const randomTopLevelDomain = () => chooseRandomly(POPULAR_TOP_LEVEL_DOMAINS); + +export const randomName = () => randomString(4, 8); diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts new file mode 100644 index 0000000000..7c64dcfd90 --- /dev/null +++ b/packages/cli/test/integration/shared/testDb.ts @@ -0,0 +1,389 @@ +import { createConnection, getConnection, ConnectionOptions } from 'typeorm'; +import { Credentials, UserSettings } from 'n8n-core'; + +import config = require('../../../config'); +import { BOOTSTRAP_MYSQL_CONNECTION_NAME, BOOTSTRAP_POSTGRES_CONNECTION_NAME } from './constants'; +import { DatabaseType, Db, ICredentialsDb, IDatabaseCollections } from '../../../src'; +import { randomEmail, randomName, randomString, randomValidPassword } from './random'; +import { CredentialsEntity } from '../../../src/databases/entities/CredentialsEntity'; + +import { RESPONSE_ERROR_MESSAGES } from '../../../src/constants'; +import { entities } from '../../../src/databases/entities'; +import { mysqlMigrations } from '../../../src/databases/mysqldb/migrations'; +import { postgresMigrations } from '../../../src/databases/postgresdb/migrations'; +import { sqliteMigrations } from '../../../src/databases/sqlite/migrations'; + +import type { Role } from '../../../src/databases/entities/Role'; +import type { User } from '../../../src/databases/entities/User'; +import type { CredentialPayload } from './types'; + +/** + * Initialize one test DB per suite run, with bootstrap connection if needed. + */ +export async function init() { + const dbType = config.get('database.type') as DatabaseType; + + if (dbType === 'sqlite') { + // no bootstrap connection required + const testDbName = `n8n_test_sqlite_${randomString(6, 10)}_${Date.now()}`; + await Db.init(getSqliteOptions({ name: testDbName })); + await getConnection(testDbName).runMigrations({ transaction: 'none' }); + + return { testDbName }; + } + + if (dbType === 'postgresdb') { + let bootstrapPostgres; + const bootstrapPostgresOptions = getBootstrapPostgresOptions(); + + try { + bootstrapPostgres = await createConnection(bootstrapPostgresOptions); + } catch (error) { + const { username, password, host, port, schema } = bootstrapPostgresOptions; + console.error( + `ERROR: Failed to connect to Postgres default DB 'postgres'.\nPlease review your Postgres connection options:\n\thost: "${host}"\n\tusername: "${username}"\n\tpassword: "${password}"\n\tport: "${port}"\n\tschema: "${schema}"\nFix by setting correct values via environment variables:\n\texport DB_POSTGRESDB_HOST=value\n\texport DB_POSTGRESDB_USER=value\n\texport DB_POSTGRESDB_PASSWORD=value\n\texport DB_POSTGRESDB_PORT=value\n\texport DB_POSTGRESDB_SCHEMA=value`, + ); + process.exit(1); + } + + const testDbName = `pg_${randomString(6, 10)}_${Date.now()}_n8n_test`; + await bootstrapPostgres.query(`CREATE DATABASE ${testDbName};`); + + await Db.init(getPostgresOptions({ name: testDbName })); + + return { testDbName }; + } + + if (dbType === 'mysqldb') { + const bootstrapMysql = await createConnection(getBootstrapMySqlOptions()); + + const testDbName = `mysql_${randomString(6, 10)}_${Date.now()}_n8n_test`; + await bootstrapMysql.query(`CREATE DATABASE ${testDbName};`); + + await Db.init(getMySqlOptions({ name: testDbName })); + + return { testDbName }; + } + + throw new Error(`Unrecognized DB type: ${dbType}`); +} + +/** + * Drop test DB, closing bootstrap connection if existing. + */ +export async function terminate(testDbName: string) { + const dbType = config.get('database.type') as DatabaseType; + + if (dbType === 'sqlite') { + await getConnection(testDbName).close(); + } + + if (dbType === 'postgresdb') { + await getConnection(testDbName).close(); + + const bootstrapPostgres = getConnection(BOOTSTRAP_POSTGRES_CONNECTION_NAME); + await bootstrapPostgres.query(`DROP DATABASE ${testDbName}`); + await bootstrapPostgres.close(); + } + + if (dbType === 'mysqldb') { + await getConnection(testDbName).close(); + + const bootstrapMySql = getConnection(BOOTSTRAP_MYSQL_CONNECTION_NAME); + await bootstrapMySql.query(`DROP DATABASE ${testDbName}`); + await bootstrapMySql.close(); + } +} + +/** + * Truncate DB tables for specified entities. + * + * @param entities Array of entity names whose tables to truncate. + * @param testDbName Name of the test DB to truncate tables in. + */ +export async function truncate(entities: Array, testDbName: string) { + const dbType = config.get('database.type'); + + if (dbType === 'sqlite') { + const testDb = getConnection(testDbName); + await testDb.query('PRAGMA foreign_keys=OFF'); + await Promise.all(entities.map((entity) => Db.collections[entity]!.clear())); + return testDb.query('PRAGMA foreign_keys=ON'); + } + + const map: { [K in keyof IDatabaseCollections]: string } = { + Credentials: 'credentials_entity', + Workflow: 'workflow_entity', + Execution: 'execution_entity', + Tag: 'tag_entity', + Webhook: 'webhook_entity', + Role: 'role', + User: 'user', + SharedCredentials: 'shared_credentials', + SharedWorkflow: 'shared_workflow', + Settings: 'settings', + }; + + if (dbType === 'postgresdb') { + return Promise.all( + entities.map((entity) => + getConnection(testDbName).query( + `TRUNCATE TABLE "${map[entity]}" RESTART IDENTITY CASCADE;`, + ), + ), + ); + } + + // MySQL truncation requires globals, which cannot be safely manipulated by parallel tests + if (dbType === 'mysqldb') { + await Promise.all( + entities.map(async (entity) => { + await Db.collections[entity]!.delete({}); + await getConnection(testDbName).query(`ALTER TABLE ${map[entity]} AUTO_INCREMENT = 1;`); + }), + ); + } +} + +// ---------------------------------- +// credential creation +// ---------------------------------- + +/** + * Save a credential to the test DB, sharing it with a user. + */ +export async function saveCredential( + credentialPayload: CredentialPayload, + { user, role }: { user: User; role: Role }, +) { + const newCredential = new CredentialsEntity(); + + Object.assign(newCredential, credentialPayload); + + const encryptedData = await encryptCredentialData(newCredential); + + Object.assign(newCredential, encryptedData); + + const savedCredential = await Db.collections.Credentials!.save(newCredential); + + savedCredential.data = newCredential.data; + + await Db.collections.SharedCredentials!.save({ + user, + credentials: savedCredential, + role, + }); + + return savedCredential; +} + +// ---------------------------------- +// user creation +// ---------------------------------- + +/** + * Store a user in the DB, defaulting to a `member`. + */ +export async function createUser(attributes: Partial = {}): Promise { + const { email, password, firstName, lastName, globalRole, ...rest } = attributes; + const user = { + email: email ?? randomEmail(), + password: password ?? randomValidPassword(), + firstName: firstName ?? randomName(), + lastName: lastName ?? randomName(), + globalRole: globalRole ?? (await getGlobalMemberRole()), + ...rest, + }; + + return Db.collections.User!.save(user); +} + +export async function createOwnerShell() { + const globalRole = await getGlobalOwnerRole(); + return Db.collections.User!.save({ globalRole }); +} + +export async function createMemberShell() { + const globalRole = await getGlobalMemberRole(); + return Db.collections.User!.save({ globalRole }); +} + +// ---------------------------------- +// role fetchers +// ---------------------------------- + +export async function getGlobalOwnerRole() { + return await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'global', + }); +} + +export async function getGlobalMemberRole() { + return await Db.collections.Role!.findOneOrFail({ + name: 'member', + scope: 'global', + }); +} + +export async function getWorkflowOwnerRole() { + return await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'workflow', + }); +} + +export async function getCredentialOwnerRole() { + return await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'credential', + }); +} + +export function getAllRoles() { + return Promise.all([ + getGlobalOwnerRole(), + getGlobalMemberRole(), + getWorkflowOwnerRole(), + getCredentialOwnerRole(), + ]); +} + +// ---------------------------------- +// connection options +// ---------------------------------- + +/** + * Generate options for an in-memory sqlite database connection, + * one per test suite run. + */ +export const getSqliteOptions = ({ name }: { name: string }): ConnectionOptions => { + return { + name, + type: 'sqlite', + database: ':memory:', + entityPrefix: '', + dropSchema: true, + migrations: sqliteMigrations, + migrationsTableName: 'migrations', + migrationsRun: false, + }; +}; + +/** + * Generate options for a bootstrap Postgres connection, + * to create and drop test Postgres databases. + */ +export const getBootstrapPostgresOptions = () => { + const username = config.get('database.postgresdb.user'); + const password = config.get('database.postgresdb.password'); + const host = config.get('database.postgresdb.host'); + const port = config.get('database.postgresdb.port'); + const schema = config.get('database.postgresdb.schema'); + + return { + name: BOOTSTRAP_POSTGRES_CONNECTION_NAME, + type: 'postgres', + database: 'postgres', // pre-existing default database + host, + port, + username, + password, + schema, + } as const; +}; + +export const getPostgresOptions = ({ name }: { name: string }): ConnectionOptions => { + const username = config.get('database.postgresdb.user'); + const password = config.get('database.postgresdb.password'); + const host = config.get('database.postgresdb.host'); + const port = config.get('database.postgresdb.port'); + const schema = config.get('database.postgresdb.schema'); + + return { + name, + type: 'postgres', + database: name, + host, + port, + username, + password, + entityPrefix: '', + schema, + dropSchema: true, + migrations: postgresMigrations, + migrationsRun: true, + migrationsTableName: 'migrations', + entities: Object.values(entities), + synchronize: false, + logging: false, + }; +}; + +/** + * Generate options for a bootstrap MySQL connection, + * to create and drop test MySQL databases. + */ +export const getBootstrapMySqlOptions = (): ConnectionOptions => { + const username = config.get('database.mysqldb.user'); + const password = config.get('database.mysqldb.password'); + const host = config.get('database.mysqldb.host'); + const port = config.get('database.mysqldb.port'); + + return { + name: BOOTSTRAP_MYSQL_CONNECTION_NAME, + database: BOOTSTRAP_MYSQL_CONNECTION_NAME, + type: 'mysql', + host, + port, + username, + password, + }; +}; + +/** + * Generate options for a MySQL database connection, + * one per test suite run. + */ +export const getMySqlOptions = ({ name }: { name: string }): ConnectionOptions => { + const username = config.get('database.mysqldb.user'); + const password = config.get('database.mysqldb.password'); + const host = config.get('database.mysqldb.host'); + const port = config.get('database.mysqldb.port'); + + return { + name, + database: name, + type: 'mysql', + host, + port, + username, + password, + migrations: mysqlMigrations, + migrationsTableName: 'migrations', + migrationsRun: true, + }; +}; + +// ---------------------------------- +// encryption +// ---------------------------------- + +async function encryptCredentialData(credential: CredentialsEntity) { + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { + throw new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY); + } + + const coreCredential = new Credentials( + { id: null, name: credential.name }, + credential.type, + credential.nodesAccess, + ); + + // @ts-ignore + coreCredential.setData(credential.data, encryptionKey); + + return coreCredential.getDataToSave() as ICredentialsDb; +} diff --git a/packages/cli/test/integration/shared/types.d.ts b/packages/cli/test/integration/shared/types.d.ts new file mode 100644 index 0000000000..b160faea01 --- /dev/null +++ b/packages/cli/test/integration/shared/types.d.ts @@ -0,0 +1,28 @@ +import type { ICredentialDataDecryptedObject, ICredentialNodeAccess } from 'n8n-workflow'; +import type { ICredentialsDb } from '../../../src'; +import type { CredentialsEntity } from '../../../src/databases/entities/CredentialsEntity'; +import type { User } from '../../../src/databases/entities/User'; + +export type SmtpTestAccount = { + user: string; + pass: string; + smtp: { + host: string; + port: number; + secure: boolean; + }; +}; + +type EndpointGroup = 'me' | 'users' | 'auth' | 'owner' | 'passwordReset' | 'credentials'; + +export type CredentialPayload = { + name: string; + type: string; + nodesAccess: ICredentialNodeAccess[]; + data: ICredentialDataDecryptedObject; +}; + +export type SaveCredentialFunction = ( + credentialPayload: CredentialPayload, + { user }: { user: User }, +) => Promise; diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts new file mode 100644 index 0000000000..45f97cfabf --- /dev/null +++ b/packages/cli/test/integration/shared/utils.ts @@ -0,0 +1,220 @@ +import { randomBytes } from 'crypto'; +import { existsSync } from 'fs'; +import express = require('express'); +import * as superagent from 'superagent'; +import * as request from 'supertest'; +import { URL } from 'url'; +import bodyParser = require('body-parser'); +import * as util from 'util'; +import { createTestAccount } from 'nodemailer'; +import { INodeTypes, LoggerProxy } from 'n8n-workflow'; +import { UserSettings } from 'n8n-core'; + +import config = require('../../../config'); +import { AUTHLESS_ENDPOINTS, REST_PATH_SEGMENT } from './constants'; +import { AUTH_COOKIE_NAME } from '../../../src/constants'; +import { addRoutes as authMiddleware } from '../../../src/UserManagement/routes'; +import { Db, ExternalHooks, InternalHooksManager } from '../../../src'; +import { meNamespace as meEndpoints } from '../../../src/UserManagement/routes/me'; +import { usersNamespace as usersEndpoints } from '../../../src/UserManagement/routes/users'; +import { authenticationMethods as authEndpoints } from '../../../src/UserManagement/routes/auth'; +import { ownerNamespace as ownerEndpoints } from '../../../src/UserManagement/routes/owner'; +import { passwordResetNamespace as passwordResetEndpoints } from '../../../src/UserManagement/routes/passwordReset'; +import { issueJWT } from '../../../src/UserManagement/auth/jwt'; +import { getLogger } from '../../../src/Logger'; +import { credentialsController } from '../../../src/api/credentials.api'; + +import type { User } from '../../../src/databases/entities/User'; +import { Telemetry } from '../../../src/telemetry'; +import type { EndpointGroup, SmtpTestAccount } from './types'; +import type { N8nApp } from '../../../src/UserManagement/Interfaces'; + +/** + * Initialize a test server. + * + * @param applyAuth Whether to apply auth middleware to test server. + * @param endpointGroups Groups of endpoints to apply to test server. + */ +export function initTestServer({ + applyAuth, + endpointGroups, +}: { + applyAuth: boolean; + endpointGroups?: EndpointGroup[]; +}) { + const testServer = { + app: express(), + restEndpoint: REST_PATH_SEGMENT, + ...(endpointGroups?.includes('credentials') ? { externalHooks: ExternalHooks() } : {}), + }; + + testServer.app.use(bodyParser.json()); + testServer.app.use(bodyParser.urlencoded({ extended: true })); + + config.set('userManagement.jwtSecret', 'My JWT secret'); + config.set('userManagement.isInstanceOwnerSetUp', false); + + if (applyAuth) { + authMiddleware.apply(testServer, [AUTHLESS_ENDPOINTS, REST_PATH_SEGMENT]); + } + + if (!endpointGroups) return testServer.app; + + const [routerEndpoints, functionEndpoints] = classifyEndpointGroups(endpointGroups); + + if (routerEndpoints.length) { + const map: Record = { + credentials: credentialsController, + }; + + for (const group of routerEndpoints) { + testServer.app.use(`/${testServer.restEndpoint}/${group}`, map[group]); + } + } + + if (functionEndpoints.length) { + const map: Record void> = { + me: meEndpoints, + users: usersEndpoints, + auth: authEndpoints, + owner: ownerEndpoints, + passwordReset: passwordResetEndpoints, + }; + + for (const group of functionEndpoints) { + map[group].apply(testServer); + } + } + + return testServer.app; +} + +export function initTestTelemetry() { + const mockNodeTypes = { nodeTypes: {} } as INodeTypes; + + void InternalHooksManager.init('test-instance-id', 'test-version', mockNodeTypes); + + jest.spyOn(Telemetry.prototype, 'track').mockResolvedValue(); +} + +/** + * Classify endpoint groups into `routerEndpoints` (newest, using `express.Router`), + * and `functionEndpoints` (legacy, namespaced inside a function). + */ +const classifyEndpointGroups = (endpointGroups: string[]) => { + const routerEndpoints: string[] = []; + const functionEndpoints: string[] = []; + + endpointGroups.forEach((group) => + (group === 'credentials' ? routerEndpoints : functionEndpoints).push(group), + ); + + return [routerEndpoints, functionEndpoints]; +}; + +// ---------------------------------- +// initializers +// ---------------------------------- + +/** + * Initialize a silent logger for test runs. + */ +export function initTestLogger() { + config.set('logs.output', 'file'); // declutter console output + LoggerProxy.init(getLogger()); +} + +/** + * Initialize a user settings config file if non-existent. + */ +export function initConfigFile() { + const settingsPath = UserSettings.getUserSettingsPath(); + + if (!existsSync(settingsPath)) { + const userSettings = { encryptionKey: randomBytes(24).toString('base64') }; + UserSettings.writeUserSettings(userSettings, settingsPath); + } +} + +// ---------------------------------- +// request agent +// ---------------------------------- + +/** + * Create a request agent, optionally with an auth cookie. + */ +export function createAgent(app: express.Application, options?: { auth: true; user: User }) { + const agent = request.agent(app); + agent.use(prefix(REST_PATH_SEGMENT)); + + if (options?.auth && options?.user) { + const { token } = issueJWT(options.user); + agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`); + } + + return agent; +} + +/** + * Plugin to prefix a path segment into a request URL pathname. + * + * Example: http://127.0.0.1:62100/me/password โ†’ http://127.0.0.1:62100/rest/me/password + */ +export function prefix(pathSegment: string) { + return function (request: superagent.SuperAgentRequest) { + const url = new URL(request.url); + + // enforce consistency at call sites + if (url.pathname[0] !== '/') { + throw new Error('Pathname must start with a forward slash'); + } + + url.pathname = pathSegment + url.pathname; + request.url = url.toString(); + + return request; + }; +} + +/** + * Extract the value (token) of the auth cookie in a response. + */ +export function getAuthToken(response: request.Response, authCookieName = AUTH_COOKIE_NAME) { + const cookies: string[] = response.headers['set-cookie']; + + if (!cookies) { + throw new Error("No 'set-cookie' header found in response"); + } + + const authCookie = cookies.find((c) => c.startsWith(`${authCookieName}=`)); + + if (!authCookie) return undefined; + + const match = authCookie.match(new RegExp(`(^| )${authCookieName}=(?[^;]+)`)); + + if (!match || !match.groups) return undefined; + + return match.groups.token; +} + +// ---------------------------------- +// settings +// ---------------------------------- + +export async function isInstanceOwnerSetUp() { + const { value } = await Db.collections.Settings!.findOneOrFail({ + key: 'userManagement.isInstanceOwnerSetUp', + }); + + return Boolean(value); +} + +// ---------------------------------- +// SMTP +// ---------------------------------- + +/** + * Get an SMTP test account from https://ethereal.email to test sending emails. + */ +export const getSmtpTestAccount = util.promisify(createTestAccount); + diff --git a/packages/cli/test/integration/users.endpoints.test.ts b/packages/cli/test/integration/users.endpoints.test.ts new file mode 100644 index 0000000000..d5179950b9 --- /dev/null +++ b/packages/cli/test/integration/users.endpoints.test.ts @@ -0,0 +1,600 @@ +import express = require('express'); +import validator from 'validator'; +import { v4 as uuid } from 'uuid'; +import { compare } from 'bcryptjs'; + +import { Db } from '../../src'; +import config = require('../../config'); +import { SUCCESS_RESPONSE_BODY } from './shared/constants'; +import { Role } from '../../src/databases/entities/Role'; +import { + randomEmail, + randomValidPassword, + randomName, + randomInvalidPassword, +} from './shared/random'; +import { CredentialsEntity } from '../../src/databases/entities/CredentialsEntity'; +import { WorkflowEntity } from '../../src/databases/entities/WorkflowEntity'; +import * as utils from './shared/utils'; +import * as testDb from './shared/testDb'; + +let app: express.Application; +let testDbName = ''; +let globalOwnerRole: Role; +let globalMemberRole: Role; +let workflowOwnerRole: Role; +let credentialOwnerRole: Role; + +beforeAll(async () => { + app = utils.initTestServer({ endpointGroups: ['users'], applyAuth: true }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + const [ + fetchedGlobalOwnerRole, + fetchedGlobalMemberRole, + fetchedWorkflowOwnerRole, + fetchedCredentialOwnerRole, + ] = await testDb.getAllRoles(); + + globalOwnerRole = fetchedGlobalOwnerRole; + globalMemberRole = fetchedGlobalMemberRole; + workflowOwnerRole = fetchedWorkflowOwnerRole; + credentialOwnerRole = fetchedCredentialOwnerRole; + + utils.initTestTelemetry(); + utils.initTestLogger(); +}); + +beforeEach(async () => { + // do not combine calls - shared tables must be cleared first and separately + await testDb.truncate(['SharedCredentials', 'SharedWorkflow'], testDbName); + await testDb.truncate(['User', 'Workflow', 'Credentials'], testDbName); + + jest.isolateModules(() => { + jest.mock('../../config'); + }); + + await testDb.createUser({ + id: INITIAL_TEST_USER.id, + email: INITIAL_TEST_USER.email, + password: INITIAL_TEST_USER.password, + firstName: INITIAL_TEST_USER.firstName, + lastName: INITIAL_TEST_USER.lastName, + globalRole: globalOwnerRole, + }); + + config.set('userManagement.disabled', false); + config.set('userManagement.isInstanceOwnerSetUp', true); + config.set('userManagement.emails.mode', ''); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +test('GET /users should return all users', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + await testDb.createUser(); + + const response = await authOwnerAgent.get('/users'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + for (const user of response.body.data) { + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + } = user; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBeDefined(); + expect(firstName).toBeDefined(); + expect(lastName).toBeDefined(); + expect(personalizationAnswers).toBeUndefined(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(isPending).toBe(false); + expect(globalRole).toBeDefined(); + } +}); + +test('DELETE /users/:id should delete the user', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const userToDelete = await testDb.createUser(); + + const newWorkflow = new WorkflowEntity(); + + Object.assign(newWorkflow, { + name: randomName(), + active: false, + connections: {}, + nodes: [], + }); + + const savedWorkflow = await Db.collections.Workflow!.save(newWorkflow); + + await Db.collections.SharedWorkflow!.save({ + role: workflowOwnerRole, + user: userToDelete, + workflow: savedWorkflow, + }); + + const newCredential = new CredentialsEntity(); + + Object.assign(newCredential, { + name: randomName(), + data: '', + type: '', + nodesAccess: [], + }); + + const savedCredential = await Db.collections.Credentials!.save(newCredential); + + await Db.collections.SharedCredentials!.save({ + role: credentialOwnerRole, + user: userToDelete, + credentials: savedCredential, + }); + + const response = await authOwnerAgent.delete(`/users/${userToDelete.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); + + const user = await Db.collections.User!.findOne(userToDelete.id); + expect(user).toBeUndefined(); // deleted + + const sharedWorkflow = await Db.collections.SharedWorkflow!.findOne({ + relations: ['user'], + where: { user: userToDelete }, + }); + expect(sharedWorkflow).toBeUndefined(); // deleted + + const sharedCredential = await Db.collections.SharedCredentials!.findOne({ + relations: ['user'], + where: { user: userToDelete }, + }); + expect(sharedCredential).toBeUndefined(); // deleted + + const workflow = await Db.collections.Workflow!.findOne(savedWorkflow.id); + expect(workflow).toBeUndefined(); // deleted + + // TODO: Include active workflow and check whether webhook has been removed + + const credential = await Db.collections.Credentials!.findOne(savedCredential.id); + expect(credential).toBeUndefined(); // deleted +}); + +test('DELETE /users/:id should fail to delete self', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.delete(`/users/${owner.id}`); + + expect(response.statusCode).toBe(400); + + const user = await Db.collections.User!.findOne(owner.id); + expect(user).toBeDefined(); +}); + +test('DELETE /users/:id should fail if user to delete is transferee', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const { id: idToDelete } = await testDb.createUser(); + + const response = await authOwnerAgent.delete(`/users/${idToDelete}`).query({ + transferId: idToDelete, + }); + + expect(response.statusCode).toBe(400); + + const user = await Db.collections.User!.findOne(idToDelete); + expect(user).toBeDefined(); +}); + +test('DELETE /users/:id with transferId should perform transfer', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const userToDelete = await Db.collections.User!.save({ + id: uuid(), + email: randomEmail(), + password: randomValidPassword(), + firstName: randomName(), + lastName: randomName(), + createdAt: new Date(), + updatedAt: new Date(), + globalRole: workflowOwnerRole, + }); + + const newWorkflow = new WorkflowEntity(); + + Object.assign(newWorkflow, { + name: randomName(), + active: false, + connections: {}, + nodes: [], + }); + + const savedWorkflow = await Db.collections.Workflow!.save(newWorkflow); + + await Db.collections.SharedWorkflow!.save({ + role: workflowOwnerRole, + user: userToDelete, + workflow: savedWorkflow, + }); + + const newCredential = new CredentialsEntity(); + + Object.assign(newCredential, { + name: randomName(), + data: '', + type: '', + nodesAccess: [], + }); + + const savedCredential = await Db.collections.Credentials!.save(newCredential); + + await Db.collections.SharedCredentials!.save({ + role: credentialOwnerRole, + user: userToDelete, + credentials: savedCredential, + }); + + const response = await authOwnerAgent.delete(`/users/${userToDelete.id}`).query({ + transferId: owner.id, + }); + + expect(response.statusCode).toBe(200); + + const sharedWorkflow = await Db.collections.SharedWorkflow!.findOneOrFail({ + relations: ['user'], + where: { user: owner }, + }); + + const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({ + relations: ['user'], + where: { user: owner }, + }); + + const deletedUser = await Db.collections.User!.findOne(userToDelete); + + expect(sharedWorkflow.user.id).toBe(owner.id); + expect(sharedCredential.user.id).toBe(owner.id); + expect(deletedUser).toBeUndefined(); +}); + +test('GET /resolve-signup-token should validate invite token', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const { id: inviteeId } = await testDb.createMemberShell(); + + const response = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: INITIAL_TEST_USER.id }) + .query({ inviteeId }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + data: { + inviter: { + firstName: INITIAL_TEST_USER.firstName, + lastName: INITIAL_TEST_USER.lastName, + }, + }, + }); +}); + +test('GET /resolve-signup-token should fail with invalid inputs', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const { id: inviteeId } = await testDb.createUser(); + + const first = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: INITIAL_TEST_USER.id }); + + const second = await authOwnerAgent.get('/resolve-signup-token').query({ inviteeId }); + + const third = await authOwnerAgent.get('/resolve-signup-token').query({ + inviterId: '5531199e-b7ae-425b-a326-a95ef8cca59d', + inviteeId: 'cb133beb-7729-4c34-8cd1-a06be8834d9d', + }); + + // user is already set up, so call should error + const fourth = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: INITIAL_TEST_USER.id }) + .query({ inviteeId }); + + // cause inconsistent DB state + await Db.collections.User!.update(owner.id, { email: '' }); + const fifth = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: INITIAL_TEST_USER.id }) + .query({ inviteeId }); + + for (const response of [first, second, third, fourth, fifth]) { + expect(response.statusCode).toBe(400); + } +}); + +test('POST /users/:id should fill out a user shell', async () => { + const authlessAgent = utils.createAgent(app); + + const userToFillOut = await Db.collections.User!.save({ + email: randomEmail(), + globalRole: globalMemberRole, + }); + + const newPassword = randomValidPassword(); + + const response = await authlessAgent.post(`/users/${userToFillOut.id}`).send({ + inviterId: INITIAL_TEST_USER.id, + firstName: INITIAL_TEST_USER.firstName, + lastName: INITIAL_TEST_USER.lastName, + password: newPassword, + }); + + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + password, + resetPasswordToken, + globalRole, + isPending, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBeDefined(); + expect(firstName).toBe(INITIAL_TEST_USER.firstName); + expect(lastName).toBe(INITIAL_TEST_USER.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(isPending).toBe(false); + expect(globalRole).toBeDefined(); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeDefined(); + + const filledOutUser = await Db.collections.User!.findOneOrFail(userToFillOut.id); + expect(filledOutUser.firstName).toBe(INITIAL_TEST_USER.firstName); + expect(filledOutUser.lastName).toBe(INITIAL_TEST_USER.lastName); + expect(filledOutUser.password).not.toBe(newPassword); +}); + +test('POST /users/:id should fail with invalid inputs', async () => { + const authlessAgent = utils.createAgent(app); + + const emailToStore = randomEmail(); + + const userToFillOut = await Db.collections.User!.save({ + email: emailToStore, + globalRole: globalMemberRole, + }); + + for (const invalidPayload of INVALID_FILL_OUT_USER_PAYLOADS) { + const response = await authlessAgent.post(`/users/${userToFillOut.id}`).send(invalidPayload); + expect(response.statusCode).toBe(400); + + const user = await Db.collections.User!.findOneOrFail({ where: { email: emailToStore } }); + expect(user.firstName).toBeNull(); + expect(user.lastName).toBeNull(); + expect(user.password).toBeNull(); + } +}); + +test('POST /users/:id should fail with already accepted invite', async () => { + const authlessAgent = utils.createAgent(app); + + const globalMemberRole = await Db.collections.Role!.findOneOrFail({ + name: 'member', + scope: 'global', + }); + + const shell = await Db.collections.User!.save({ + email: randomEmail(), + password: randomValidPassword(), // simulate accepted invite + globalRole: globalMemberRole, + }); + + const newPassword = randomValidPassword(); + + const response = await authlessAgent.post(`/users/${shell.id}`).send({ + inviterId: INITIAL_TEST_USER.id, + firstName: randomName(), + lastName: randomName(), + password: newPassword, + }); + + expect(response.statusCode).toBe(400); + + const fetchedShell = await Db.collections.User!.findOneOrFail({ where: { email: shell.email } }); + expect(fetchedShell.firstName).toBeNull(); + expect(fetchedShell.lastName).toBeNull(); + + const comparisonResult = await compare(shell.password, newPassword); + expect(comparisonResult).toBe(false); + expect(newPassword).not.toBe(fetchedShell.password); +}); + +test('POST /users should fail if emailing is not set up', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.post('/users').send([{ email: randomEmail() }]); + + expect(response.statusCode).toBe(500); +}); + +test('POST /users should fail if user management is disabled', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + config.set('userManagement.disabled', true); + + const response = await authOwnerAgent.post('/users').send([{ email: randomEmail() }]); + + expect(response.statusCode).toBe(500); +}); + +test('POST /users should email invites and create user shells', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const { + user, + pass, + smtp: { host, port, secure }, + } = await utils.getSmtpTestAccount(); + + config.set('userManagement.emails.mode', 'smtp'); + config.set('userManagement.emails.smtp.host', host); + config.set('userManagement.emails.smtp.port', port); + config.set('userManagement.emails.smtp.secure', secure); + config.set('userManagement.emails.smtp.auth.user', user); + config.set('userManagement.emails.smtp.auth.pass', pass); + + const payload = TEST_EMAILS_TO_CREATE_USER_SHELLS.map((e) => ({ email: e })); + + const response = await authOwnerAgent.post('/users').send(payload); + + expect(response.statusCode).toBe(200); + + for (const { + user: { id, email: receivedEmail }, + error, + } of response.body.data) { + expect(validator.isUUID(id)).toBe(true); + expect(TEST_EMAILS_TO_CREATE_USER_SHELLS.some((e) => e === receivedEmail)).toBe(true); + if (error) { + expect(error).toBe('Email could not be sent'); + } + + const user = await Db.collections.User!.findOneOrFail(id); + const { firstName, lastName, personalizationAnswers, password, resetPasswordToken } = user; + + expect(firstName).toBeNull(); + expect(lastName).toBeNull(); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeNull(); + expect(resetPasswordToken).toBeNull(); + } +}); + +test('POST /users should fail with invalid inputs', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + config.set('userManagement.emails.mode', 'smtp'); + + const invalidPayloads = [ + randomEmail(), + [randomEmail()], + {}, + [{ name: randomName() }], + [{ email: randomName() }], + ]; + + for (const invalidPayload of invalidPayloads) { + const response = await authOwnerAgent.post('/users').send(invalidPayload); + expect(response.statusCode).toBe(400); + + const users = await Db.collections.User!.find(); + expect(users.length).toBe(1); // DB unaffected + } +}); + +test('POST /users should ignore an empty payload', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + config.set('userManagement.emails.mode', 'smtp'); + + const response = await authOwnerAgent.post('/users').send([]); + + const { data } = response.body; + + expect(response.statusCode).toBe(200); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(0); + + const users = await Db.collections.User!.find(); + expect(users.length).toBe(1); +}); + +// TODO: /users/:id/reinvite route tests missing + +// TODO: UserManagementMailer is a singleton - cannot reinstantiate with wrong creds +// test('POST /users should error for wrong SMTP config', async () => { +// const owner = await Db.collections.User!.findOneOrFail(); +// const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + +// config.set('userManagement.emails.mode', 'smtp'); +// config.set('userManagement.emails.smtp.host', 'XYZ'); // break SMTP config + +// const payload = TEST_EMAILS_TO_CREATE_USER_SHELLS.map((e) => ({ email: e })); + +// const response = await authOwnerAgent.post('/users').send(payload); + +// expect(response.statusCode).toBe(500); +// }); + +const INITIAL_TEST_USER = { + id: uuid(), + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), +}; + +const INVALID_FILL_OUT_USER_PAYLOADS = [ + { + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), + }, + { + inviterId: INITIAL_TEST_USER.id, + firstName: randomName(), + password: randomValidPassword(), + }, + { + inviterId: INITIAL_TEST_USER.id, + firstName: randomName(), + password: randomValidPassword(), + }, + { + inviterId: INITIAL_TEST_USER.id, + firstName: randomName(), + lastName: randomName(), + }, + { + inviterId: INITIAL_TEST_USER.id, + firstName: randomName(), + lastName: randomName(), + password: randomInvalidPassword(), + }, +]; + +const TEST_EMAILS_TO_CREATE_USER_SHELLS = [randomEmail(), randomEmail(), randomEmail()]; diff --git a/packages/cli/test/setup.ts b/packages/cli/test/setup.ts new file mode 100644 index 0000000000..fa881e3fd3 --- /dev/null +++ b/packages/cli/test/setup.ts @@ -0,0 +1,34 @@ +import { exec as callbackExec } from 'child_process'; +import { promisify } from 'util'; + +import config = require('../config'); +import { BOOTSTRAP_MYSQL_CONNECTION_NAME } from './integration/shared/constants'; +import { DatabaseType } from '../src'; + +const exec = promisify(callbackExec); + +const dbType = config.get('database.type') as DatabaseType; + +if (dbType === 'mysqldb') { + const username = config.get('database.mysqldb.user'); + const password = config.get('database.mysqldb.password'); + const host = config.get('database.mysqldb.host'); + + const passwordSegment = password ? `-p${password}` : ''; + + (async () => { + try { + jest.setTimeout(30000); // 30 seconds for DB initialization + await exec( + `echo "CREATE DATABASE IF NOT EXISTS ${BOOTSTRAP_MYSQL_CONNECTION_NAME}" | mysql -h ${host} -u ${username} ${passwordSegment}; USE ${BOOTSTRAP_MYSQL_CONNECTION_NAME};`, + ); + } catch (error) { + if (error.stderr.includes('Access denied')) { + console.error( + `ERROR: Failed to log into MySQL to create bootstrap DB.\nPlease review your MySQL connection options:\n\thost: "${host}"\n\tusername: "${username}"\n\tpassword: "${password}"\nFix by setting correct values via environment variables.\n\texport DB_MYSQLDB_HOST=value\n\texport DB_MYSQLDB_USERNAME=value\n\texport DB_MYSQLDB_PASSWORD=value`, + ); + process.exit(1); + } + } + })(); +} diff --git a/packages/cli/test/teardown.ts b/packages/cli/test/teardown.ts new file mode 100644 index 0000000000..95fb810e39 --- /dev/null +++ b/packages/cli/test/teardown.ts @@ -0,0 +1,48 @@ +import { createConnection } from 'typeorm'; +import config = require('../config'); +import { exec } from 'child_process'; +import { DatabaseType } from '../src'; +import { getBootstrapMySqlOptions, getBootstrapPostgresOptions } from './integration/shared/testDb'; +import { BOOTSTRAP_MYSQL_CONNECTION_NAME } from './integration/shared/constants'; + +export default async () => { + const dbType = config.get('database.type') as DatabaseType; + + if (dbType === 'postgresdb') { + const bootstrapPostgres = await createConnection(getBootstrapPostgresOptions()); + + const results: { db_name: string }[] = await bootstrapPostgres.query( + 'SELECT datname as db_name FROM pg_database;', + ); + + const promises = results + .filter(({ db_name: dbName }) => dbName.startsWith('pg_') && dbName.endsWith('_n8n_test')) + .map(({ db_name: dbName }) => bootstrapPostgres.query(`DROP DATABASE ${dbName};`)); + + await Promise.all(promises); + + bootstrapPostgres.close(); + } + + if (dbType === 'mysqldb') { + const user = config.get('database.mysqldb.user'); + const password = config.get('database.mysqldb.password'); + const host = config.get('database.mysqldb.host'); + + const bootstrapMySql = await createConnection(getBootstrapMySqlOptions()); + + const results: { Database: string }[] = await bootstrapMySql.query('SHOW DATABASES;'); + + const promises = results + .filter(({ Database: dbName }) => dbName.startsWith('mysql_') && dbName.endsWith('_n8n_test')) + .map(({ Database: dbName }) => bootstrapMySql.query(`DROP DATABASE ${dbName};`)); + + await Promise.all(promises); + + await bootstrapMySql.close(); + + exec( + `echo "DROP DATABASE ${BOOTSTRAP_MYSQL_CONNECTION_NAME}" | mysql -h ${host} -u ${user} -p${password}`, + ); + } +}; diff --git a/packages/cli/test/CredentialsHelper.test.ts b/packages/cli/test/unit/CredentialsHelper.test.ts similarity index 99% rename from packages/cli/test/CredentialsHelper.test.ts rename to packages/cli/test/unit/CredentialsHelper.test.ts index e26131f579..7617630588 100644 --- a/packages/cli/test/CredentialsHelper.test.ts +++ b/packages/cli/test/unit/CredentialsHelper.test.ts @@ -1,4 +1,4 @@ -import { CredentialsHelper, CredentialTypes } from '../src'; +import { CredentialsHelper, CredentialTypes } from '../../src'; import * as Helpers from './Helpers'; import { IAuthenticateBasicAuth, diff --git a/packages/cli/test/Helpers.ts b/packages/cli/test/unit/Helpers.ts similarity index 100% rename from packages/cli/test/Helpers.ts rename to packages/cli/test/unit/Helpers.ts diff --git a/packages/core/package.json b/packages/core/package.json index 0262cb088b..e4d36ef240 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,15 +30,15 @@ "@types/cron": "~1.7.1", "@types/crypto-js": "^4.0.1", "@types/express": "^4.17.6", - "@types/jest": "^26.0.13", + "@types/jest": "^27.4.0", "@types/lodash.get": "^4.4.6", "@types/mime-types": "^2.1.0", "@types/node": "14.17.27", "@types/request-promise-native": "~1.0.15", "@types/uuid": "^8.3.4", - "jest": "^26.4.2", + "jest": "^27.4.7", "source-map-support": "^0.5.9", - "ts-jest": "^26.3.0", + "ts-jest": "^27.1.3", "tslint": "^6.1.2", "typescript": "~4.3.5" }, diff --git a/packages/core/src/ActiveWebhooks.ts b/packages/core/src/ActiveWebhooks.ts index fa0ee578c9..518ba1d64e 100644 --- a/packages/core/src/ActiveWebhooks.ts +++ b/packages/core/src/ActiveWebhooks.ts @@ -51,7 +51,7 @@ export class ActiveWebhooks { // check that there is not a webhook already registed with that path/method if (this.webhookUrls[webhookKey] && !webhookData.webhookId) { throw new Error( - `Test-Webhook can not be activated because another one with the same method "${webhookData.httpMethod}" and path "${webhookData.path}" is already active!`, + `The URL path that the "${webhookData.node}" node uses is already taken. Please change it to something else.`, ); } diff --git a/packages/core/test/Helpers.ts b/packages/core/test/Helpers.ts index 6f55523372..f40dcb4bf5 100644 --- a/packages/core/test/Helpers.ts +++ b/packages/core/test/Helpers.ts @@ -797,5 +797,6 @@ export function WorkflowExecuteAdditionalData( webhookBaseUrl: 'webhook', webhookWaitingBaseUrl: 'webhook-waiting', webhookTestBaseUrl: 'webhook-test', + userId: '123', }; } diff --git a/packages/design-system/.storybook/font-awesome-icons.js b/packages/design-system/.storybook/font-awesome-icons.js index d839486fc0..8713b3b661 100644 --- a/packages/design-system/.storybook/font-awesome-icons.js +++ b/packages/design-system/.storybook/font-awesome-icons.js @@ -3,171 +3,6 @@ * Editor icons are defined seperately */ import { library } from '@fortawesome/fontawesome-svg-core'; -import { - faAngleDoubleLeft, - faAngleDown, - faAngleRight, - faAngleUp, - faArrowLeft, - faArrowRight, - faAt, - faBook, - faBug, - faCalendar, - faCheck, - faChevronDown, - faChevronUp, - faChevronLeft, - faChevronRight, - faCode, - faCodeBranch, - faCog, - faCogs, - faClone, - faCloud, - faCloudDownloadAlt, - faCopy, - faCut, - faDotCircle, - faEdit, - faEnvelope, - faEye, - faExclamationTriangle, - faExpand, - faExternalLinkAlt, - faExchangeAlt, - faFile, - faFileArchive, - faFileCode, - faFileDownload, - faFileExport, - faFileImport, - faFilePdf, - faFolderOpen, - faGift, - faHdd, - faHome, - faHourglass, - faImage, - faInbox, - faInfo, - faInfoCircle, - faKey, - faMapSigns, - faNetworkWired, - faPause, - faPen, - faPlay, - faPlayCircle, - faPlus, - faPlusCircle, - faQuestion, - faQuestionCircle, - faRedo, - faRss, - faSave, - faSearch, - faSearchMinus, - faSearchPlus, - faServer, - faSignInAlt, - faSlidersH, - faSpinner, - faStop, - faSun, - faSync, - faSyncAlt, - faTable, - faTasks, - faTerminal, - faThLarge, - faTimes, - faTrash, - faUndo, - faUsers, - faClock, -} from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; +import { fas } from '@fortawesome/free-solid-svg-icons'; -library.add(faAngleDoubleLeft); -library.add(faAngleDown); -library.add(faAngleRight); -library.add(faAngleUp); -library.add(faArrowLeft); -library.add(faArrowRight); -library.add(faAt); -library.add(faBook); -library.add(faBug); -library.add(faCalendar); -library.add(faCheck); -library.add(faChevronDown); -library.add(faChevronUp); -library.add(faChevronLeft); -library.add(faChevronRight); -library.add(faCode); -library.add(faCodeBranch); -library.add(faCog); -library.add(faCogs); -library.add(faClone); -library.add(faCloud); -library.add(faCloudDownloadAlt); -library.add(faCopy); -library.add(faCut); -library.add(faDotCircle); -library.add(faEdit); -library.add(faEnvelope); -library.add(faEye); -library.add(faExclamationTriangle); -library.add(faExpand); -library.add(faExternalLinkAlt); -library.add(faExchangeAlt); -library.add(faFile); -library.add(faFileArchive); -library.add(faFileCode); -library.add(faFileDownload); -library.add(faFileExport); -library.add(faFileImport); -library.add(faFilePdf); -library.add(faFolderOpen); -library.add(faGift); -library.add(faHdd); -library.add(faHome); -library.add(faHourglass); -library.add(faImage); -library.add(faInbox); -library.add(faInfo); -library.add(faInfoCircle); -library.add(faKey); -library.add(faMapSigns); -library.add(faNetworkWired); -library.add(faPause); -library.add(faPen); -library.add(faPlay); -library.add(faPlayCircle); -library.add(faPlus); -library.add(faPlusCircle); -library.add(faQuestion); -library.add(faQuestionCircle); -library.add(faRedo); -library.add(faRss); -library.add(faSave); -library.add(faSearch); -library.add(faSearchMinus); -library.add(faSearchPlus); -library.add(faServer); -library.add(faSignInAlt); -library.add(faSlidersH); -library.add(faSpinner); -library.add(faStop); -library.add(faSun); -library.add(faSync); -library.add(faSyncAlt); -library.add(faTable); -library.add(faTasks); -library.add(faTerminal); -library.add(faThLarge); -library.add(faTimes); -library.add(faTrash); -library.add(faUndo); -library.add(faUsers); -library.add(faClock); +library.add(fas); diff --git a/packages/design-system/.storybook/main.js b/packages/design-system/.storybook/main.js index 9cd8b91c03..bc1d34f6b4 100644 --- a/packages/design-system/.storybook/main.js +++ b/packages/design-system/.storybook/main.js @@ -19,7 +19,9 @@ module.exports = { { loader: 'css-loader', options: { - modules: true, + modules: { + localIdentName: '[path][name]__[local]--[hash:base64:5]', + }, }, }, 'sass-loader', diff --git a/packages/design-system/.storybook/preview.js b/packages/design-system/.storybook/preview.js index e914e03921..3c85a33dba 100644 --- a/packages/design-system/.storybook/preview.js +++ b/packages/design-system/.storybook/preview.js @@ -59,7 +59,7 @@ export const parameters = { }, options: { storySort: { - order: ['Docs', 'Styleguide', 'Atoms'], + order: ['Docs', 'Styleguide', 'Atoms', 'Modules'], }, }, }; diff --git a/packages/design-system/package.json b/packages/design-system/package.json index addcc14f65..83a2b35f1e 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -32,21 +32,17 @@ "core-js": "3.x" }, "devDependencies": { + "@babel/core": "^7.14.6", "@fortawesome/fontawesome-svg-core": "^1.2.35", "@fortawesome/free-solid-svg-icons": "^5.15.3", "@fortawesome/vue-fontawesome": "^2.0.2", "core-js": "^3.6.5", "element-ui": "~2.15.7", - "storybook-addon-themes": "^6.1.0", - "vue": "^2.6.11", - "vue-class-component": "^7.2.3", - "vue-property-decorator": "^9.1.2", - "@babel/core": "^7.14.6", "@storybook/addon-actions": "^6.3.6", "@storybook/addon-essentials": "^6.3.6", "@storybook/addon-links": "^6.3.6", "@storybook/vue": "^6.3.6", - "@types/jest": "^26.0.13", + "@types/jest": "^27.4.0", "@types/markdown-it": "^12.2.3", "@typescript-eslint/eslint-plugin": "^4.29.0", "@typescript-eslint/parser": "^4.29.0", @@ -63,6 +59,10 @@ "eslint-plugin-prettier": "^3.4.0", "eslint-plugin-vue": "^7.16.0", "gulp": "^4.0.0", + "gulp-autoprefixer": "^4.0.0", + "gulp-clean-css": "^4.3.0", + "gulp-dart-sass": "^1.0.2", + "node-notifier": ">=8.0.1", "markdown-it": "^12.3.2", "markdown-it-emoji": "^2.0.0", "markdown-it-link-attributes": "^4.0.0", @@ -71,14 +71,15 @@ "sass": "^1.26.5", "sass-loader": "^8.0.2", "storybook-addon-designs": "^6.0.1", - "typescript": "~4.3.5", - "vue-loader": "^15.9.7", - "vue-template-compiler": "^2.6.11", - "gulp-autoprefixer": "^4.0.0", - "gulp-clean-css": "^4.3.0", - "gulp-dart-sass": "^1.0.2", - "node-notifier": ">=8.0.1", + "storybook-addon-themes": "^6.1.0", "trim": ">=0.0.3", + "typescript": "~4.3.5", + "vue": "^2.6.11", + "vue-class-component": "^7.2.3", + "vue-loader": "^15.9.7", + "vue-property-decorator": "^9.1.2", + "vue-template-compiler": "^2.6.11", + "vue2-boring-avatars": "0.3.4", "xss": "^1.0.10" } } diff --git a/packages/design-system/src/components/N8nActionBox/ActionBox.stories.js b/packages/design-system/src/components/N8nActionBox/ActionBox.stories.js new file mode 100644 index 0000000000..fa59b17fb3 --- /dev/null +++ b/packages/design-system/src/components/N8nActionBox/ActionBox.stories.js @@ -0,0 +1,33 @@ +import N8nActionBox from './ActionBox.vue'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Atoms/ActionBox', + component: N8nActionBox, + argTypes: { + }, + parameters: { + backgrounds: { default: '--color-background-light' }, + }, +}; + +const methods = { + onClick: action('click'), +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nActionBox, + }, + template: '', + methods, +}); + +export const ActionBox = Template.bind({}); +ActionBox.args = { + emoji: "๐Ÿ˜ฟ", + heading: "Headline you need to know", + description: "Long description that you should know something is the way it is because of how it is. ", + buttonText: "Do something", +}; diff --git a/packages/design-system/src/components/N8nActionBox/ActionBox.vue b/packages/design-system/src/components/N8nActionBox/ActionBox.vue new file mode 100644 index 0000000000..4e6c0a5441 --- /dev/null +++ b/packages/design-system/src/components/N8nActionBox/ActionBox.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/packages/design-system/src/components/N8nActionBox/index.js b/packages/design-system/src/components/N8nActionBox/index.js new file mode 100644 index 0000000000..0842e0d8f7 --- /dev/null +++ b/packages/design-system/src/components/N8nActionBox/index.js @@ -0,0 +1,3 @@ +import N8nActionBox from './ActionBox.vue'; + +export default N8nActionBox; diff --git a/packages/design-system/src/components/N8nActionToggle/ActionToggle.stories.js b/packages/design-system/src/components/N8nActionToggle/ActionToggle.stories.js new file mode 100644 index 0000000000..62ea3289f8 --- /dev/null +++ b/packages/design-system/src/components/N8nActionToggle/ActionToggle.stories.js @@ -0,0 +1,43 @@ +import N8nActionToggle from './ActionToggle.vue'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Atoms/ActionToggle', + component: N8nActionToggle, + argTypes: { + placement: { + type: 'select', + options: ['top', 'bottom'], + }, + }, + parameters: { + backgrounds: { default: '--color-background-light' }, + }, +}; + +const methods = { + onAction: action('action'), +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nActionToggle, + }, + template: '
', + methods, +}); + +export const ActionToggle = Template.bind({}); +ActionToggle.args = { + actions: [ + { + label: 'Go', + value: 'go', + }, + { + label: 'Stop', + value: 'stop', + }, + ], +}; diff --git a/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue b/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue new file mode 100644 index 0000000000..c73f324309 --- /dev/null +++ b/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/packages/design-system/src/components/N8nActionToggle/index.js b/packages/design-system/src/components/N8nActionToggle/index.js new file mode 100644 index 0000000000..f4174607a0 --- /dev/null +++ b/packages/design-system/src/components/N8nActionToggle/index.js @@ -0,0 +1,3 @@ +import N8nActionToggle from './ActionToggle.vue'; + +export default N8nActionToggle; diff --git a/packages/design-system/src/components/N8nAvatar/Avatar.stories.js b/packages/design-system/src/components/N8nAvatar/Avatar.stories.js new file mode 100644 index 0000000000..a1d2201275 --- /dev/null +++ b/packages/design-system/src/components/N8nAvatar/Avatar.stories.js @@ -0,0 +1,25 @@ +import N8nAvatar from './Avatar.vue'; + +export default { + title: 'Atoms/Avatar', + component: N8nAvatar, + argTypes: { + size: { + type: 'select', + options: ['small', 'medium', 'large'], + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nAvatar, + }, + template: '', +}); + +export const Avatar = Template.bind({}); +Avatar.args = { + name: 'Sunny Side', +}; diff --git a/packages/design-system/src/components/N8nAvatar/Avatar.vue b/packages/design-system/src/components/N8nAvatar/Avatar.vue new file mode 100644 index 0000000000..6666155df8 --- /dev/null +++ b/packages/design-system/src/components/N8nAvatar/Avatar.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/packages/design-system/src/components/N8nAvatar/index.js b/packages/design-system/src/components/N8nAvatar/index.js new file mode 100644 index 0000000000..416e198c54 --- /dev/null +++ b/packages/design-system/src/components/N8nAvatar/index.js @@ -0,0 +1,3 @@ +import N8nAvatar from './Avatar.vue'; + +export default N8nAvatar; diff --git a/packages/design-system/src/components/N8nBadge/Badge.stories.js b/packages/design-system/src/components/N8nBadge/Badge.stories.js new file mode 100644 index 0000000000..dd73349200 --- /dev/null +++ b/packages/design-system/src/components/N8nBadge/Badge.stories.js @@ -0,0 +1,28 @@ +import N8nBadge from './Badge.vue'; + +export default { + title: 'Atoms/Badge', + component: N8nBadge, + argTypes: { + theme: { + type: 'text', + options: ['default', 'secondary'], + }, + size: { + type: 'select', + options: ['small', 'medium', 'large'], + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nBadge, + }, + template: + 'Badge', +}); + +export const Badge = Template.bind({}); +Badge.args = {}; diff --git a/packages/design-system/src/components/N8nBadge/Badge.vue b/packages/design-system/src/components/N8nBadge/Badge.vue new file mode 100644 index 0000000000..4b49cc65f4 --- /dev/null +++ b/packages/design-system/src/components/N8nBadge/Badge.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/packages/design-system/src/components/N8nBadge/index.js b/packages/design-system/src/components/N8nBadge/index.js new file mode 100644 index 0000000000..c3100fe17d --- /dev/null +++ b/packages/design-system/src/components/N8nBadge/index.js @@ -0,0 +1,3 @@ +import N8nBadge from './Badge.vue'; + +export default N8nBadge; diff --git a/packages/design-system/src/components/N8nButton/Button.vue b/packages/design-system/src/components/N8nButton/Button.vue index ff8d78b407..8fc7a2c3a8 100644 --- a/packages/design-system/src/components/N8nButton/Button.vue +++ b/packages/design-system/src/components/N8nButton/Button.vue @@ -47,18 +47,18 @@ export default { type: String, default: 'primary', validator: (value: string): boolean => - ['primary', 'outline', 'light', 'text'].indexOf(value) !== -1, + ['primary', 'outline', 'light', 'text'].includes(value), }, theme: { type: String, validator: (value: string): boolean => - ['success', 'warning', 'danger'].indexOf(value) !== -1, + ['success', 'warning', 'danger'].includes(value), }, size: { type: String, default: 'medium', validator: (value: string): boolean => - ['mini', 'small', 'medium', 'large', 'xlarge'].indexOf(value) !== -1, + ['mini', 'small', 'medium', 'large', 'xlarge'].includes(value), }, loading: { type: Boolean, @@ -82,7 +82,7 @@ export default { float: { type: String, validator: (value: string): boolean => - ['left', 'right'].indexOf(value) !== -1, + ['left', 'right'].includes(value), }, fullWidth: { type: Boolean, @@ -122,9 +122,7 @@ export default { diff --git a/packages/design-system/src/components/N8nFormBox/index.js b/packages/design-system/src/components/N8nFormBox/index.js new file mode 100644 index 0000000000..a033f23368 --- /dev/null +++ b/packages/design-system/src/components/N8nFormBox/index.js @@ -0,0 +1,3 @@ +import N8nFormBox from './FormBox.vue'; + +export default N8nFormBox; diff --git a/packages/design-system/src/components/N8nFormInput/FormInput.stories.js b/packages/design-system/src/components/N8nFormInput/FormInput.stories.js new file mode 100644 index 0000000000..376d761ca8 --- /dev/null +++ b/packages/design-system/src/components/N8nFormInput/FormInput.stories.js @@ -0,0 +1,35 @@ +import N8nFormInput from './FormInput.vue'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Modules/FormInput', + component: N8nFormInput, + argTypes: { + }, +}; + +const methods = { + onInput: action('input'), + onFocus: action('focus'), + onChange: action('change'), +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nFormInput, + }, + template: '', + methods, + data() { + return { + val: '', + }; + }, +}); + +export const FormInput = Template.bind({}); +FormInput.args = { + label: 'Label', + placeholder: 'placeholder', +}; diff --git a/packages/design-system/src/components/N8nFormInput/FormInput.vue b/packages/design-system/src/components/N8nFormInput/FormInput.vue new file mode 100644 index 0000000000..d9e6fefb20 --- /dev/null +++ b/packages/design-system/src/components/N8nFormInput/FormInput.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/packages/design-system/src/components/N8nFormInput/index.js b/packages/design-system/src/components/N8nFormInput/index.js new file mode 100644 index 0000000000..c243c51f0d --- /dev/null +++ b/packages/design-system/src/components/N8nFormInput/index.js @@ -0,0 +1,3 @@ +import N8nFormInput from './FormInput.vue'; + +export default N8nFormInput; diff --git a/packages/design-system/src/components/N8nFormInput/validators.ts b/packages/design-system/src/components/N8nFormInput/validators.ts new file mode 100644 index 0000000000..963e533e55 --- /dev/null +++ b/packages/design-system/src/components/N8nFormInput/validators.ts @@ -0,0 +1,158 @@ + +import { IValidator, RuleGroup } from "../../../../editor-ui/src/Interface"; + +export const emailRegex = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + +export const VALIDATORS: { [key: string]: IValidator | RuleGroup } = { + REQUIRED: { + validate: (value: string | number | boolean | null | undefined) => { + if (typeof value === 'string' && !!value.trim()) { + return false; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return false; + } + + return { + messageKey: 'formInput.validator.fieldRequired', + }; + }, + }, + MIN_LENGTH: { + validate: (value: string | number | boolean | null | undefined, config: { minimum: number }) => { + if (typeof value === 'string' && value.length < config.minimum) { + return { + messageKey: 'formInput.validator.minCharactersRequired', + options: config, + }; + } + + return false; + }, + }, + MAX_LENGTH: { + validate: (value: string | number | boolean | null | undefined, config: { maximum: number }) => { + if (typeof value === 'string' && value.length > config.maximum) { + return { + messageKey: 'formInput.validator.maxCharactersRequired', + options: config, + }; + } + + return false; + }, + }, + CONTAINS_NUMBER: { + validate: (value: string | number | boolean | null | undefined, config: { minimum: number }) => { + if (typeof value !== 'string') { + return false; + } + + const numberCount = (value.match(/\d/g) || []).length; + if (numberCount < config.minimum) { + return { + messageKey: 'formInput.validator.numbersRequired', + options: config, + }; + } + + return false; + }, + }, + VALID_EMAIL: { + validate: (value: string | number | boolean | null | undefined) => { + if (!emailRegex.test(String(value).trim().toLowerCase())) { + return { + messageKey: 'formInput.validator.validEmailRequired', + }; + } + + return false; + }, + }, + CONTAINS_UPPERCASE: { + validate: (value: string | number | boolean | null | undefined, config: { minimum: number }) => { + if (typeof value !== 'string') { + return false; + } + + const uppercaseCount = (value.match(/[A-Z]/g) || []).length; + if (uppercaseCount < config.minimum) { + return { + messageKey: 'formInput.validator.uppercaseCharsRequired', + options: config, + }; + } + + return false; + }, + }, + DEFAULT_PASSWORD_RULES: { + rules: [ + { + rules: [ + { name: 'MIN_LENGTH', config: { minimum: 8 } }, + { name: 'CONTAINS_NUMBER', config: { minimum: 1 } }, + { name: 'CONTAINS_UPPERCASE', config: { minimum: 1 } }, + ], + defaultError: { + messageKey: 'formInput.validator.defaultPasswordRequirements', + }, + }, + { name: 'MAX_LENGTH', config: {maximum: 64} }, + ], + }, +}; + +export const getValidationError = ( + value: any, // tslint:disable-line:no-any + validators: { [key: string]: IValidator | RuleGroup }, + validator: IValidator | RuleGroup, + config?: any, // tslint:disable-line:no-any +): ReturnType => { + if (validator.hasOwnProperty('rules')) { + const rules = (validator as RuleGroup).rules; + for (let i = 0; i < rules.length; i++) { + if (rules[i].hasOwnProperty('rules')) { + const error = getValidationError( + value, + validators, + rules[i] as RuleGroup, + config, + ); + + if (error) { + return error; + } + } + + if (rules[i].hasOwnProperty('name') ) { + const rule = rules[i] as {name: string, config?: any}; // tslint:disable-line:no-any + if (!validators[rule.name]) { + continue; + } + + const error = getValidationError( + value, + validators, + validators[rule.name] as IValidator, + rule.config, + ); + if (error && (validator as RuleGroup).defaultError !== undefined) { + // @ts-ignore + return validator.defaultError; + } else if (error) { + return error; + } + } + } + } else if ( + validator.hasOwnProperty('validate') + ) { + return (validator as IValidator).validate(value, config); + } + + return false; +}; diff --git a/packages/design-system/src/components/N8nFormInputs/FormInputs.stories.js b/packages/design-system/src/components/N8nFormInputs/FormInputs.stories.js new file mode 100644 index 0000000000..e736bec3f0 --- /dev/null +++ b/packages/design-system/src/components/N8nFormInputs/FormInputs.stories.js @@ -0,0 +1,73 @@ +import N8nFormInputs from './FormInputs.vue'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Modules/FormInputs', + component: N8nFormInputs, + argTypes: {}, + parameters: { + backgrounds: { default: '--color-background-light' }, + }, +}; + +const methods = { + onInput: action('input'), + onSubmit: action('submit'), +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nFormInputs, + }, + template: '', + methods, +}); + +export const FormInputs = Template.bind({}); +FormInputs.args = { + inputs: [ + { + name: 'email', + properties: { + label: 'Your Email', + type: 'email', + required: true, + initialValue: 'test@test.com', + }, + }, + { + name: 'password', + properties: { + label: 'Your Password', + type: 'password', + required: true, + }, + }, + { + name: 'nickname', + properties: { + label: 'Your Nickname', + placeholder: 'Monty', + }, + }, + { + name: 'opts', + properties: { + type: 'select', + label: 'Opts', + options: [ + { + label: 'Opt1', + value: 'opt1', + }, + { + label: 'Opt2', + value: 'opt2', + }, + ], + }, + }, + ], +}; + diff --git a/packages/design-system/src/components/N8nFormInputs/FormInputs.vue b/packages/design-system/src/components/N8nFormInputs/FormInputs.vue new file mode 100644 index 0000000000..881f76d041 --- /dev/null +++ b/packages/design-system/src/components/N8nFormInputs/FormInputs.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/packages/design-system/src/components/N8nFormInputs/index.js b/packages/design-system/src/components/N8nFormInputs/index.js new file mode 100644 index 0000000000..0ecba6da91 --- /dev/null +++ b/packages/design-system/src/components/N8nFormInputs/index.js @@ -0,0 +1,3 @@ +import N8nFormInputs from './FormInputs.vue'; + +export default N8nFormInputs; diff --git a/packages/design-system/src/components/N8nHeading/Heading.vue b/packages/design-system/src/components/N8nHeading/Heading.vue index 3a66b3be03..a608fa17d7 100644 --- a/packages/design-system/src/components/N8nHeading/Heading.vue +++ b/packages/design-system/src/components/N8nHeading/Heading.vue @@ -1,5 +1,5 @@ @@ -25,16 +25,23 @@ export default { type: String, validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'].includes(value), }, + align: { + type: String, + validator: (value: string): boolean => ['right', 'left', 'center'].includes(value), + }, }, methods: { - getClass(props: {size: string, bold: boolean}) { - return `heading-${props.size}${props.bold ? '-bold' : '-regular'}`; + getClasses(props: {size: string, bold: boolean}, $style: any) { + return {[$style[`size-${props.size}`]]: true, [$style.bold]: props.bold, [$style.regular]: !props.bold}; }, getStyles(props: {color: string}) { const styles = {} as any; if (props.color) { styles.color = `var(--color-${props.color})`; } + if (props.align) { + styles['text-align'] = props.align; + } return styles; }, }, @@ -50,79 +57,29 @@ export default { font-weight: var(--font-weight-regular); } -.heading-2xlarge { +.size-2xlarge { font-size: var(--font-size-2xl); line-height: var(--font-line-height-compact); } -.heading-2xlarge-regular { - composes: regular; - composes: heading-2xlarge; -} - -.heading-2xlarge-bold { - composes: bold; - composes: heading-2xlarge; -} - -.heading-xlarge { +.size-xlarge { font-size: var(--font-size-xl); line-height: var(--font-line-height-compact); } -.heading-xlarge-regular { - composes: regular; - composes: heading-xlarge; -} - -.heading-xlarge-bold { - composes: bold; - composes: heading-xlarge; -} - -.heading-large { +.size-large { font-size: var(--font-size-l); line-height: var(--font-line-height-loose); } -.heading-large-regular { - composes: regular; - composes: heading-large; -} - -.heading-large-bold { - composes: bold; - composes: heading-large; -} - -.heading-medium { +.size-medium { font-size: var(--font-size-m); line-height: var(--font-line-height-loose); } -.heading-medium-regular { - composes: regular; - composes: heading-medium; -} - -.heading-medium-bold { - composes: bold; - composes: heading-medium; -} - -.heading-small { +.size-small { font-size: var(--font-size-s); line-height: var(--font-line-height-regular); } -.heading-small-regular { - composes: regular; - composes: heading-small; -} - -.heading-small-bold { - composes: bold; - composes: heading-small; -} - diff --git a/packages/design-system/src/components/N8nIcon/index.d.ts b/packages/design-system/src/components/N8nIcon/index.d.ts deleted file mode 100644 index 62181a33b6..0000000000 --- a/packages/design-system/src/components/N8nIcon/index.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { N8nComponent, N8nComponentSize } from '../component'; - -/** Button Component */ -export declare class N8nIcon extends N8nComponent { - /** icon name, accepts an icon name of font awesome icon component */ - icon: string; - - /** Size of icon */ - size: N8nComponentSize; - - /** Whether icon should be spinning */ - spin: boolean; -} diff --git a/packages/design-system/src/components/N8nIcon/index.js b/packages/design-system/src/components/N8nIcon/index.js index aeb12bb638..56e98507bf 100644 --- a/packages/design-system/src/components/N8nIcon/index.js +++ b/packages/design-system/src/components/N8nIcon/index.js @@ -1,3 +1,3 @@ -import Icon from './Icon.vue'; +import N8nIcon from './Icon.vue'; -export default Icon; +export default N8nIcon; diff --git a/packages/design-system/src/components/N8nInput/Input.stories.js b/packages/design-system/src/components/N8nInput/Input.stories.js index ee8c637f9a..d96e50964a 100644 --- a/packages/design-system/src/components/N8nInput/Input.stories.js +++ b/packages/design-system/src/components/N8nInput/Input.stories.js @@ -8,7 +8,7 @@ export default { argTypes: { type: { control: 'select', - options: ['text', 'textarea'], + options: ['text', 'textarea', 'number', 'password', 'email'], }, placeholder: { control: 'text', @@ -30,8 +30,8 @@ export default { const methods = { onInput: action('input'), - onFocus: action('input'), - onChange: action('input'), + onFocus: action('focus'), + onChange: action('change'), }; const Template = (args, { argTypes }) => ({ diff --git a/packages/design-system/src/components/N8nInput/Input.vue b/packages/design-system/src/components/N8nInput/Input.vue index 50734fcdee..4280bdbd98 100644 --- a/packages/design-system/src/components/N8nInput/Input.vue +++ b/packages/design-system/src/components/N8nInput/Input.vue @@ -5,6 +5,7 @@ :size="$options.methods.getSize(props.size)" :class="$style[$options.methods.getClass(props)]" :ref="data.ref" + :autoComplete="props.autocomplete" v-on="listeners" >