feat: Add User Management (#2636)

*  adjust tests

* 🛠 refactor user invites to be indempotent (#2791)

* 🔐 Encrypt SMTP pass for user management backend (#2793)

* 📦 Add crypto-js to /cli

* 📦 Update package-lock.json

*  Create type for SMTP config

*  Encrypt SMTP pass

*  Update format for `userManagement.emails.mode`

*  Update format for `binaryDataManager.mode`

*  Update format for `logs.level`

* 🔥 Remove logging

* 👕 Fix lint

* 👰  n8n 2826 um wedding FE<>BE (#2789)

* remove mocks

* update authorization func

* lock down default role

* 🐛 fix requiring authentication for OPTIONS requests

* 🐛 fix cors and cookie issues in dev

* update setup route

Co-authored-by: Ben Hesseldieck <b.hesseldieck@gmail.com>

* update telemetry

* 🐛 preload role for users

* 🐛 remove auth for password reset routes

* 🐛 fix forgot-password flow

*  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

*  add env check for tag create

* 🧪 Add `/users` tests for user management backend (#2790)

*  Refactor users namespace

*  Adjust fillout endpoint

*  Refactor initTestServer arg

* ✏️ Specify agent type

* ✏️ Specify role type

*  Tighten `/users/:id` check

*  Add initial tests

* 🚚 Reposition init server map

*  Set constants in `validatePassword()`

*  Tighten `/users/:id` check

*  Improve checks in `/users/:id`

*  Add tests for `/users/:id`

* 📦 Update package-lock.json

*  Simplify expectation

*  Reuse util for authless agent

* 🚚 Make role names consistent

* 📘 Tighten namespaces map type

* 🔥 Remove unneeded default arg

*  Add tests for `POST /users`

* 📘 Create test SMTP account type

* ✏️ Improve wording

* 🎨 Formatting

* 🔥 Remove temp fix

*  Replace helper with config call

*  Fix failing tests

* 🔥 Remove outdated test

* 🔥 Remove unused helper

*  Increase readability of domain fetcher

*  Refactor payload validation

* 🔥 Remove repetition

*  Restore logging

*  Initialize logger in tests

* 🔥 Remove redundancy from check

* 🚚 Move `globalOwnerRole` fetching to global scope

* 🔥 Remove unused imports

* 🚚 Move random utils to own module

* 🚚 Move test types to own module

* ✏️ Add dividers to utils

* ✏️ Reorder `initTestServer` param docstring

* ✏️ Add TODO comment

*  Dry up member creation

*  Tighten search criteria

* 🧪 Add expectation to `GET /users`

*  Create role fetcher utils

*  Create one more role fetch util

* 🔥 Remove unneeded DB query

* 🧪 Add expectation to `POST /users`

* 🧪 Add expectation to `DELETE /users/:id`

* 🧪 Add another expectation to `DELETE /users/:id`

* 🧪 Add expectations to `DELETE /users/:id`

* 🧪 Adjust expectations in `POST /users/:id`

* 🧪 Add expectations to `DELETE /users/:id`

* 👕 Fix build

*  Update method

* 📘 Fix `userToDelete` type

*  Refactor `createAgent()`

*  Make role fetching global

*  Optimize roles fetching

*  Centralize member creation

*  Refactor truncation helper

* 🧪 Add teardown to `DELETE /users/:id`

* 🧪 Add DB expectations to users tests

* 🔥 Remove pass validation due to hash

* ✏️ Improve pass validation error message

*  Improve owner pass validation

*  Create logger initialization helper

*  Optimize helpers

*  Restructure `getAllRoles` helper

* 🧪 Add password reset flow tests for user management backend (#2807)

*  Refactor users namespace

*  Adjust fillout endpoint

*  Refactor initTestServer arg

* ✏️ Specify agent type

* ✏️ Specify role type

*  Tighten `/users/:id` check

*  Add initial tests

* 🚚 Reposition init server map

*  Set constants in `validatePassword()`

*  Tighten `/users/:id` check

*  Improve checks in `/users/:id`

*  Add tests for `/users/:id`

* 📦 Update package-lock.json

*  Simplify expectation

*  Reuse util for authless agent

* 🚚 Make role names consistent

* 📘 Tighten namespaces map type

* 🔥 Remove unneeded default arg

*  Add tests for `POST /users`

* 📘 Create test SMTP account type

* ✏️ Improve wording

* 🎨 Formatting

* 🔥 Remove temp fix

*  Replace helper with config call

*  Fix failing tests

* 🔥 Remove outdated test

*  Add tests for password reset flow

* ✏️ Fix test wording

*  Set password reset namespace

* 🔥 Remove unused helper

*  Increase readability of domain fetcher

*  Refactor payload validation

* 🔥 Remove repetition

*  Restore logging

*  Initialize logger in tests

* 🔥 Remove redundancy from check

* 🚚 Move `globalOwnerRole` fetching to global scope

* 🔥 Remove unused imports

* 🚚 Move random utils to own module

* 🚚 Move test types to own module

* ✏️ Add dividers to utils

* ✏️ Reorder `initTestServer` param docstring

* ✏️ Add TODO comment

*  Dry up member creation

*  Tighten search criteria

* 🧪 Add expectation to `GET /users`

*  Create role fetcher utils

*  Create one more role fetch util

* 🔥 Remove unneeded DB query

* 🧪 Add expectation to `POST /users`

* 🧪 Add expectation to `DELETE /users/:id`

* 🧪 Add another expectation to `DELETE /users/:id`

* 🧪 Add expectations to `DELETE /users/:id`

* 🧪 Adjust expectations in `POST /users/:id`

* 🧪 Add expectations to `DELETE /users/:id`

* 📘 Add namespace name to type

* 🚚 Adjust imports

*  Optimize `globalOwnerRole` fetching

* 🧪 Add expectations

* 👕 Fix build

* 👕 Fix build

*  Update method

*  Update method

* 🧪 Fix `POST /change-password` test

* 📘 Fix `userToDelete` type

*  Refactor `createAgent()`

*  Make role fetching global

*  Optimize roles fetching

*  Centralize member creation

*  Refactor truncation helper

* 🧪 Add teardown to `DELETE /users/:id`

* 🧪 Add DB expectations to users tests

*  Refactor as in users namespace

* 🧪 Add expectation to `POST /change-password`

* 🔥 Remove pass validation due to hash

* ✏️ Improve pass validation error message

*  Improve owner pass validation

*  Create logger initialization helper

*  Optimize helpers

*  Restructure `getAllRoles` helper

*  Update `truncate` calls

* 🐛 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 <ahsan.virani@gmail.com>

* Update packages/editor-ui/src/components/Telemetry.vue

Co-authored-by: Ahsan Virani <ahsan.virani@gmail.com>

* Update packages/editor-ui/src/plugins/telemetry/index.ts

Co-authored-by: Ahsan Virani <ahsan.virani@gmail.com>

* Update packages/editor-ui/src/plugins/telemetry/index.ts

Co-authored-by: Ahsan Virani <ahsan.virani@gmail.com>

* Update packages/editor-ui/src/plugins/telemetry/index.ts

Co-authored-by: Ahsan Virani <ahsan.virani@gmail.com>

* 🚚 Fix imports

*  reset password just if password exists

* Fix validation at `PATCH /workfows/:id` (#2819)

* 🐛 Validate entity only if workflow

* 👕 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 <b.hesseldieck@gmail.com>

*  treat skipped personalizationSurvey as not answered

* 🐛 removing active workflows when deleting user, 🐛 fix reinvite, 🐛 fix resolve-signup-token, 🐘 remove workflowname uniqueness

*  Add DB state check tests (#2841)

* 🔥 Remove unneeded import

* 🔥 Remove unneeded vars

* ✏️ Improve naming

* 🧪 Add expectations to `POST /owner`

* 🧪 Add expectations to `PATCH /me`

* 🧪 Add expectation to `PATCH /me/password`

* ✏️ Clarify when owner is owner shell

* 🧪 Add more expectations

*  Restore package-lock to parent branch state

* Add logging to user management endpoints v2 (#2836)

*  Initialize logger in tests

*  Add logs to mailer

*  Add logs to middleware

*  Add logs to me endpoints

*  Add logs to owner endpoints

*  Add logs to pass flow endpoints

*  Add logs to users endpoints

* 📘 Improve typings

*  Merge two logs into one

*  Adjust log type

*  Add password reset email log

* ✏️ Reword log message

*  Adjust log meta object

*  Add total to log

* ✏️ Add detail to log message

* ✏️ Reword log message

* ✏️ Reword log message

* 🐛 Make total users to set up accurate

* ✏️ Reword `Logger.debug()` messages

* ✏️ Phrasing change for consistency

* 🐛 Fix ID overridden in range query

* 🔨 small refactoring

* 🔐 add auth to push-connection

* 🛠   Create credentials namespace and add tests (#2831)

* 🧪 Fix failing test

* 📘 Improve `createAgent` signature

* 🚚 Fix `LoggerProxy` import

*  Create credentials endpoints namespace

* 🧪 Set up initial tests

*  Add validation to model

*  Adjust validation

* 🧪 Add test

* 🚚 Sort creds endpoints

* ✏️ Plan out pending tests

* 🧪 Add deletion tests

* 🧪 Add patch tests

* 🧪 Add get cred tests

* 🚚 Hoist import

* ✏️ Make test descriptions consistent

* ✏️ Adjust description

* 🧪 Add missing test

* ✏️ Make get descriptions consistent

*  Undo line break

*  Refactor to simplify `saveCredential`

* 🧪 Add non-owned tests for owner

* ✏️ Improve naming

* ✏️ Add clarifying comments

* 🚚 Improve imports

*  Initialize config file

* 🔥 Remove unneeded import

* 🚚 Rename dir

*  Adjust deletion call

*  Adjust error code

* ✏️ Touch up comment

*  Optimize fetching with `@RelationId`

* 🧪 Add expectations

*  Simplify mock calls

* 📘 Set deep readonly to object constants

* 🔥 Remove unused param and encryption key

*  Add more `@RelationId` calls in models

*  Restore

* 🐛 no auth for .svg

* 🛠 move auth cookie name to constant; 🐛 fix auth for push-connection

*  Add auth middleware tests (#2853)

*  Simplify existing suite

* 🧪 Validate that auth cookie exists

* ✏️ Move comment

* 🔥 Remove unneeded imports

* ✏️ Add clarifying comments

* ✏️ Document auth endpoints

* 🧪 Add middleware tests

* ✏️ Fix typos

Co-authored-by: Ben Hesseldieck <1849459+BHesseldieck@users.noreply.github.com>

* 🔥 Remove test description wrappers (#2874)

* 🔥 Remove /owner test wrappers

* 🔥 Remove auth middleware test wrappers

* 🔥 Remove auth endpoints test wrappers

* 🔥 Remove overlooked middleware wrappers

* 🔥 Remove me namespace test wrappers

Co-authored-by: Ben Hesseldieck <b.hesseldieck@gmail.com>

*  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

*  remove personalizationAnswers when fetching all users

*  fix failing get all users test

*  check authorization routes also for authentication

* 🐛 fix defaults in reset command

* 🛠 refactorings from walkthrough (#2856)

*  Make `getTemplate` async

*  Remove query builder from `getCredentials`

*  Add save manual executions log message

*  Restore and hide migrations logs

*  Centralize ignore paths check

* 👕 Fix build

* 🚚 Rename `hasOwner` to `isInstanceOwnerSetUp`

*  Add `isSetUp` flag to `User`

*  Add `isSetUp` to FE interface

*  Adjust `isSetUp` checks on FE

* 👕 Fix build

*  Adjust `isPendingUser()` check

* 🚚 Shorten helper name

*  Refactor as `isPending` per feedback

* ✏️ Update log message

*  Broaden check

* 🔥 Remove unneeded relation

*  Refactor query

* 🔥 Re-remove logs from migrations

* 🛠 set up credentials router (#2882)

*  Refactor creds endpoints into router

* 🧪 Refactor creds tests to use router

* 🚚 Rename arg for consistency

* 🚚 Move `credentials.api.ts` outside /public

* 🚚 Rename constant for consistency

* 📘 Simplify types

* 🔥 Remove unneeded arg

* 🚚 Rename router to controller

*  Shorten endpoint

*  Update `initTestServer()` arg

*  Mutate response body in GET /credentials

* 🏎 improve performance of type cast for FE

Co-authored-by: Ben Hesseldieck <b.hesseldieck@gmail.com>

* 🐛 remove GET /login from auth

* 🔀 merge master + FE update (#2905)

*  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

*  Fix placeholder in creds config modal

* ✏️ Adjust docstring to `credText` placeholder version

* N8N-2853 Optimized

* N8N-2853 Optimized code

*  Add deployment type to FE settings

*  Add deployment type to interfaces

* N8N-2853 Removed Animated prop from components

*  Add deployment type to store module

*  Create hiring banner

*  Display hiring banner

*  Undo unrelated change

* N8N-2853 Refactor TemplateFilters

*  Fix indentation

* N8N-2853 Reorder items / TemplateList

* 👕 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

* ✏️ Update jobs URL

* 🚚 Move logging to root component

*  Refactor `deploymentType` to `isInternalUser`

*  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

* ⬆️ Update package-lock.json file

*  Add n8n version as header

Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>
Co-authored-by: Mutasem <mutdmour@gmail.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>

* 🔖 Release n8n-workflow@0.88.0

* ⬆️ Set n8n-workflow@0.88.0 on n8n-core

* 🔖 Release n8n-core@0.106.0

* ⬆️ Set n8n-core@0.106.0 and n8n-workflow@0.88.0 on n8n-node-dev

* 🔖 Release n8n-node-dev@0.45.0

* ⬆️ Set n8n-core@0.106.0 and n8n-workflow@0.88.0 on n8n-nodes-base

* 🔖 Release n8n-nodes-base@0.163.0

* 🔖 Release n8n-design-system@0.12.0

* ⬆️ Set n8n-design-system@0.12.0 and n8n-workflow@0.88.0 on n8n-editor-ui

* 🔖 Release n8n-editor-ui@0.132.0

* ⬆️ 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

* 🔖 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 <olivertrajceski@yahoo.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>

*  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

* 🥅 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

* 🐛 fix wrong setting of User.isPending

* 🐛 fix isPending

* 🏷 add isPending to PublicUser

* 🐛 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

* 🐛 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

* 🐛 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)

*  Update password requirements

*  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)

* 🗃️ Fix Postgres migrations

*  Add DB-specific scripts

*  Set up test connections

*  Add Postgres UUID check

* 🧪 Make test adjustments for Postgres

*  Refactor connection logic

*  Set up double init for Postgres

* ✏️ Add TODOs

*  Refactor DB dropping logic

*  Implement global teardown

*  Create TypeORM wrappers

*  Initial MySQL setup

*  Clean up Postgres connection options

*  Simplify by sharing bootstrap connection name

* 🗃️ Fix MySQL migrations

* 🔥 Remove comments

*  Use ES6 imports

* 🔥 Remove outdated comments

*  Centralize bootstrap connection name handles

*  Centralize database types

* ✏️ Update comment

* 🚚 Rename `findRepository`

* 🚧 Attempt to truncate MySQL

*  Implement creds router

* 🐛 Fix duplicated MySQL bootstrap

* 🐛 Fix misresolved merge conflict

* 🗃️ Fix tags migration

* 🗃️ Fix MySQL UM migration

* 🐛 Fix MySQL parallelization issues

* 📘 Augment TypeORM to prevent error

* 🔥 Remove comments

*  Support one sqlite DB per suite run

* 🚚 Move `testDb` to own module

* 🔥 Deduplicate bootstrap Postgres logic

* 🔥 Remove unneeded comment

*  Make logger init calls consistent

* ✏️ Improve comment

* ✏️ Add dividers

* 🎨 Improve formatting

* 🔥 Remove duplicate MySQL global setting

* 🚚 Move comment

*  Update default test script

* 🔥 Remove unneeded helper

*  Unmarshal answers from Postgres

* 🐛 Phase out `isTestRun`

*  Refactor `isEmailSetup`

* 🔥 Remove unneeded imports

*  Handle bootstrap connection errors

* 🔥 Remove unneeded imports

* 🔥 Remove outdated comments

* ✏️ Fix typos

* 🚚 Relocate `answersFormatter`

*  Undo package.json miscommit

* 🔥 Remove unneeded import

*  Refactor test DB prefixing

*  Add no-leftover check to MySQL

* 📦 Update package.json

*  Autoincrement on simulated MySQL truncation

* 🔥 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)

* 🐳 Update Node.js versions of Docker images to 16

* 🐛 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

* 🐛 Fix issue with tooltips getting displayed behind node details view

* fix tooltips z-index

* move all element ui components

* update package lock

* 🐛 Fix credentials list load issue (#2931)

* always fetch credentials

* only fetch credentials once

*  Allow to disable hiring banner (#2902)

*  Add flag

*  Adjust interfaces

*  Adjust store module

*  Adjust frontend settings

*  Adjust frontend display

* 🐛 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 <mutdmour@gmail.com>

* ⬆️ Update package-lock.json file

* 🐛 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.

* 🐛 Fix some i18n line break issues

*  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

* 🔨 fixed credential test, updating CRUD methods

* 🔨 added additional fields to crm resource

* 🔨 added descriptions, fixed credentials test bug

* 🔨 standardize node and descriptions design

* 🔨 removed comments

* 🔨 added pagination to getAll operation

*  removed leftover function from previous implementation, removed required from optional fields

*  fixed id field, added indication of type and if required to field description, replaced string input in filters to fetched list of fields

* 🔨 fetching list of models from odoo, added selection of fields to be returned to predefined models, fixes accordingly to review

*  Small improvements

* 🔨 extracted adress fields into collection, changed fields to include in descriptions, minor tweaks

*  Improvements

* 🔨 working on review

* 🔨 fixed linter errors

* 🔨 review wip

* 🔨 review wip

* 🔨 review wip

*  updated display name for URL in credentials

* 🔨 added checks for valid id to delete and update

*  Minor improvements

Co-authored-by: ricardo <ricardoespinoza105@gmail.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>

* 🐛 Handle Wise SCA requests (#2734)

*  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 <jan.oberhauser@gmail.com>
Co-authored-by: Oliver Trajceski <olivertrajceski@yahoo.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
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 <ricardoespinoza105@gmail.com>
Co-authored-by: pemontto <939704+pemontto@users.noreply.github.com>

* Move owner skip from settings

* 🐛 SMTP fixes (#2937)

* 🔥 Remove `UM_` from SMTP env vars

* 🔥 Remove SMTP host default value

*  Update sender value

*  Update invite template

*  Update password reset template

*  Update `N8N_EMAIL_MODE` default value

* 🔥 Remove `EMAIL` from all SMTP vars

*  Implement `verifyConnection()`

* 🚚 Reposition comment

* ✏️ Fix typo

* ✏️ Minor env var documentation improvements

* 🎨 Fix spacing

* 🎨 Fix spacing

* 🗃️ Remove SMTP settings cache

*  Adjust log message

*  Update error message

* ✏️ Fix template typo

* ✏️ Adjust wording

*  Interpolate email into success toast

* ✏️ Adjust base message in `verifyConnection()`

*  Verify connection on password reset

*  Bring up POST /users SMTP check

* 🐛 remove cookie if cookie is not valid

*  verify connection on instantiation

Co-authored-by: Ben Hesseldieck <b.hesseldieck@gmail.com>

* 🔊 create logger helper for migrations (#2944)

* 🔥 remove unused database

* 🔊 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

*  Create telemetry mock

* 🧪 Fix tests with telemetry mock

* 🧪 Fix missing key in authless endpoint

* 📘 Create authless request type

* 🔥 Remove log

* 🐛 Fix `migration_strategy` assignment

* 📘 Remove `instance_id` from `ITelemetryUserDeletionData`

*  Simplify concatenation

*  Simplify `track()` call signature

* Fixed payload of telemetry to always include user_id

* Fixing minor issues

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>

* 🔊 Added logs to credentials, executions and workflows (#2915)

* Added logs to credentials, executions and workflows

* Some updates according to ivov's feedback

*  update log levels

*  fix tests

Co-authored-by: Ben Hesseldieck <b.hesseldieck@gmail.com>

* 🐛 fix telemetry error

* fix conflicts with master

* fix duplicate

* add package-lock

* 🐛 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

*  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

*  correct logic of error message

* refactor view names

*  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 <ivov.src@gmail.com>
Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>
Co-authored-by: Mutasem <mutdmour@gmail.com>
Co-authored-by: Ahsan Virani <ahsan.virani@gmail.com>
Co-authored-by: Omar Ajoue <krynble@gmail.com>
Co-authored-by: Oliver Trajceski <olivertrajceski@yahoo.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
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 <ricardoespinoza105@gmail.com>
Co-authored-by: pemontto <939704+pemontto@users.noreply.github.com>
This commit is contained in:
Ben Hesseldieck
2022-03-14 14:46:32 +01:00
committed by GitHub
parent 761720621e
commit 7264239b83
262 changed files with 17737 additions and 3294 deletions

View File

@@ -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;
}

View File

@@ -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<User> {
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<Role> {
const ownerRole = await Db.collections.Role!.findOneOrFail({
where: {
name: 'owner',
scope: 'global',
},
});
return ownerRole;
}
export async function getInstanceOwner(): Promise<User> {
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<boolean> {
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<User> {
const user = await Db.collections.User!.findOneOrFail(userId, {
relations: ['globalRole'],
});
return user;
}
export async function checkPermissionsForExecution(
workflow: Workflow,
userId: string,
): Promise<boolean> {
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;
}

View File

@@ -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<User> {
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<User> {
const jwtPayload = jwt.verify(token, config.get('userManagement.jwtSecret')) as JwtPayload;
return resolveJwtContent(jwtPayload);
}
export async function issueCookie(res: Response, user: User): Promise<void> {
const userData = issueJWT(user);
res.cookie(AUTH_COOKIE_NAME, userData.token, { maxAge: userData.expiresIn, httpOnly: true });
}

View File

@@ -0,0 +1,32 @@
export interface UserManagementMailerImplementation {
sendMail: (mailData: MailData) => Promise<SendEmailResult>;
verifyConnection: () => Promise<void>;
}
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;
};

View File

@@ -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<void> {
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<SendEmailResult> {
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 };
}
}

View File

@@ -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<void> {
if (!this.mailer) return Promise.reject();
return this.mailer.verifyConnection();
}
async invite(inviteEmailData: InviteEmailData): Promise<SendEmailResult> {
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<SendEmailResult> {
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<UserManagementMailer> {
if (mailerInstance === undefined) {
mailerInstance = new UserManagementMailer();
await mailerInstance.verifyConnection();
}
return mailerInstance;
}

View File

@@ -0,0 +1,4 @@
/* eslint-disable import/no-cycle */
import { getInstance, UserManagementMailer } from './UserManagementMailer';
export { getInstance, UserManagementMailer };

View File

@@ -0,0 +1,5 @@
<h1>Hi there!</h1>
<p>Welcome to n8n, {{firstName}} {{lastName}}</p>
<p>Your instance is set up!</p>
<p>Use your email to login: {{email}} and the chosen password.</p>
<p>Have fun automating!</p>

View File

@@ -0,0 +1,4 @@
<p>Hi there,</p>
<p>You have been invited to join n8n ({{ domain }}).</p>
<p>To accept, click the following link:</p>
<p><a href="{{ inviteAcceptUrl }}" target="_blank">{{ inviteAcceptUrl }}</a></p>

View File

@@ -0,0 +1,5 @@
<p>Hi {{firstName}},</p>
<p>Somebody asked to reset your password on n8n ({{ domain }}).</p>
<p>If it was not you, you can safely ignore this email.</p>
<p>Click the following link to choose a new password. The link is valid for 2 hours.</p>
<a href="{{ passwordResetUrl }}">{{ passwordResetUrl }}</a>

View File

@@ -0,0 +1,4 @@
/* eslint-disable import/no-cycle */
import { addRoutes } from './routes';
export const userManagementRouter = { addRoutes };

View File

@@ -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<PublicUser> => {
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<PublicUser> => {
// 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<IDataObject> => {
res.clearCookie(AUTH_COOKIE_NAME);
return {
loggedOut: true,
};
}),
);
}

View File

@@ -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);
}

View File

@@ -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<PublicUser> => {
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<PublicUser> => {
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 };
}),
);
}

View File

@@ -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 };
}),
);
}

View File

@@ -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);
}),
);
}

View File

@@ -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<User>(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 };
}),
);
}