Compare commits

...

288 Commits

Author SHA1 Message Date
Gusram
f7efffcd79 Merge branch 'main' of github.com:hoppscotch/hoppscotch 2024-08-01 12:25:21 +08:00
James George
4fd6d6ddb4 fix(cli): remove postinstall script (#4229) 2024-07-31 19:10:11 +05:30
Akash K
40f93fc7f4 chore: add analytics event for ai experiments (#4224) 2024-07-30 18:55:09 +05:30
Akash K
adb7782b58 chore: enable ai experiments by default in settings (#4223) 2024-07-30 18:32:32 +05:30
Andrew Bastin
a9fc7e6a62 chore: bump version to 2024.7.0 2024-07-30 14:41:12 +05:30
Akash K
d68cfb313e feat: hoppscotch-common & platform additions for ai experiments (#4222)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-07-30 12:16:44 +05:30
Joel Jacob Stephen
2a37235229 fix(sh-admin): resolved issue while rendering images (#4221)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-07-29 22:59:02 +05:30
Mir Arif Hasan
e26528cedf fix: infra token expriry check on guard (#4219)
fix: null check added in infraToken expiry check on guard
2024-07-29 19:38:17 +05:30
James George
c9f92282bf feat: duplicate REST/GraphQL collections (#4211)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
2024-07-29 18:37:34 +05:30
Joel Jacob Stephen
c24d5c5302 feat(sh-admin): introducing infra-tokens to admin dashboard (#4202)
Co-authored-by: nivedin <nivedinp@gmail.com>
2024-07-29 17:50:06 +05:30
Mir Arif Hasan
783d911f8d HSB-462 feat: infra token module and sh apis (#4191)
* feat: infra token module added

* feat: infra token guard added

* feat: token prefix removed

* feat: get pending invites api added

* docs: swagger doc added for get user invites api

* feat: delete user invitation api added

* feat: get users api added

* feat: update user api added

* feat: update admin status api added

* feat: create invitation api added

* chore: swagger doc update for create user invite

* feat: interceptor added to track last used on

* feat: change db schema

* chore: readonly tag added

* feat: get user by id api added

* fix: return type of a function

* feat: controller name change

* chore: improve token extractino

* chore: added email validation logic

---------

Co-authored-by: Balu Babu <balub997@gmail.com>
2024-07-29 12:36:18 +05:30
Nivedin
c88ea5c8b2 fix: auth bug when value is a secret environment (#4210)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-07-27 20:54:16 +05:30
James George
5f96cda5e2 feat(cli): add support for JUnit reporter (#4189) 2024-07-26 22:56:58 +05:30
jamesgeorge007
ecf0901491 chore: merge hoppscotch/main into hoppscotch/next 2024-07-26 22:39:48 +05:30
Joel Jacob Stephen
9e445cda84 refactor(sh-admin): updated invite flow for the admin dashboard (#4176)
* feat: new success invite link modal introduced

* refactor: replaced button with lucide icon

* style: vertical padding to lucide icon

* feat: new success invite modal component

* refactor: hide copy link button when there are no pending invites

* refactor: changed copy link button to copy invite link

* chore: minor UI update

---------

Co-authored-by: nivedin <nivedinp@gmail.com>
2024-07-26 20:14:41 +05:30
Balu Babu
9cde6c597b feat: duplicate team and user collections (#4207)
* chore: created new mutation for team-collection duplication

* feat: completed duplication function in TeamCollection module

* feat: user-collection duplication complete

* chore: changed duplicated title suffix for collections

* chore: added return type to argument for duplication mutation in user-collections

* chore: capitalized duplicate in duplicate collection service4 methods

* chore: changed target of hopp-old-backend service to prod
2024-07-24 21:55:08 +05:30
Balu Babu
df730e4d21 feat: Ability to toggle cookies to work in HTTP (#4194)
* feat: added new env variable

* feat: made http secure cookie conditional

* chore: added comments to env files

* chore: changed target of hopp-old-backend service to prod
2024-07-24 21:44:39 +05:30
Andrew Bastin
4457eeb74c chore: bump versions 2024-07-22 22:12:32 +05:30
jamesgeorge007
bf5d62364d chore: bump CLI tests timeout 2024-07-22 19:21:16 +05:30
Dmitry
f913899ad5 fix: fix issue for multipart/form-data and cookie header (#4172)
* fix(multipart-formdata): fix type for data variable

* fix(multipart-formdata): fix parameter filter for unsetted file

* fix(multipart-formdata): add mime type for sending file

* fix(multipart-formdata): add application/octet-stream by default mimetype

* fix(multipart-formdata): remove content-type: multipart/form-data header

* fix(multipart-formdata): remove cookie cookie header if not present

* fix(multipart-formdata): fix content-type filter

---------

Co-authored-by: Dmitry Mukovkin <d.mukovkin@cft.ru>
2024-07-22 18:33:38 +05:30
Nivedin
7652dab9c4 fix: codemirror search bar misaligned (#4203)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-07-22 17:04:53 +05:30
Anwarul Islam
801df8ea97 feat: persist HTML response preview preference (#4177)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
2024-07-19 16:18:55 +05:30
Nivedin
25a519a2e9 fix: wrap line button showing inconsistency bug (#4188) 2024-07-18 15:20:57 +05:30
Nivedin
8c6b80dc42 fix: embeds response and request option section getting hidden bug (#4181)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-07-18 15:13:43 +05:30
Nivedin
7ebc9a6fd6 fix: secret variables being populated as undefined on code snippets (#4180) 2024-07-18 14:44:37 +05:30
Paul Vogel
7ad3f660ae i18n: add german translations (#4196)
Add german translations
2024-07-18 14:26:09 +05:30
Paul Vogel
199a7234fa i18n: fix existing german translations (#4195)
Fix existing german translations
2024-07-18 14:19:46 +05:30
Anwarul Islam
90bb470b3a fix: <img> is flex's immediate child therefore it stretch to its parent width (#4173)
* fix: img is flex immediate child and renders in full width

* chore: remove unnecesary use of flex
2024-07-17 21:34:08 +05:30
Nivedin
1dee1e1b1a chore: cleanup i18n translations (#4166) 2024-07-05 17:52:50 +03:00
Muhammed Ajmal M
71ac013a38 fix: ctrl+enter inserting newline in code (#4108)
* fix: ctrl+enter inserting newline in code

* chore: add 'cmd+enter' to the key list

---------

Co-authored-by: nivedin <nivedinp@gmail.com>
2024-07-02 15:07:27 +05:30
Nivedin
fe79c47d1f fix: codemirror crashing due to cursor not updating (#4151)
fix: cursor not updating while filtering
2024-07-02 13:41:20 +05:30
Akash K
2917d50c6a feat: copyable invite links (#4153)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-06-28 18:48:10 +03:00
Joel Jacob Stephen
0c06f26893 refactor(sh-admin): improvements to invite links in dashboard (#4152) 2024-06-28 16:15:40 +03:00
Andrew Bastin
fba22ea687 refactor: move pwa update toast to the bottom center 2024-06-28 15:10:04 +03:00
Andrew Bastin
fd60c630fd refactor: remove auto dismissal of whats new toast and move it to the bottom left 2024-06-28 15:05:16 +03:00
Mir Arif Hasan
7deb49b244 HSB-453 hotfix: remove !! operator (#4150)
fix: remove !! operator
2024-06-28 14:32:24 +05:30
jamesgeorge007
ac84166b8b chore: update documentation link references 2024-06-28 11:49:09 +05:30
jamesgeorge007
07e7180dc9 chore: update CLI documentation link references
Bump CLI version.
2024-06-28 11:16:47 +05:30
Joel Jacob Stephen
1d1462df69 feat(sh-admin): introducing advanced SMTP configurations and invite links to dashboard (#4087)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-06-27 22:15:50 +03:00
Anwarul Islam
b851d3003c feat: added change log prompt for PWA updates (#4098)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2024-06-27 19:03:33 +03:00
James George
3b70668162 feat: CLI collection runner command generation UI flow (#4141)
Co-authored-by: nivedin <nivedinp@gmail.com>
2024-06-27 19:17:56 +05:30
James George
a9afb17dc0 feat(cli): access team workspace collections and environments (#4095)
Co-authored-by: nivedin <nivedinp@gmail.com>
2024-06-27 18:11:29 +05:30
Mir Arif Hasan
fa2f73ee40 HSB-439 feature: invite link with SMTP optional (#4078)
* feat: env variable added in infra-config for smtp enable status

* feat: event emitter added

* feat: added advance mailer configurations from infra config

* test: fix test cases

* feat: added query to see is smtp enabled or not

* feat: email auth provider disabled on smtp disable

* chore: restrict on update directly instead of dedicated mutation

* fix: feedback resolved

* chore: modify mailer module

* chore: error handle in mailer functions

* chore: removed unused imports

* chore: remove event-emit

* chore: update env example

* test: fix broken test cases

* chore: feedback resolved

* chore: isSMTPEnabled moved to infra config resolver

* fix: email can not reenable if smtp not enabled
2024-06-27 17:53:40 +05:30
Joel Jacob Stephen
b3e42bf7c3 feat(sh-admin): introducing last active status to users table and individual user's page in dashboard (#4077)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-06-26 12:30:40 +05:30
jamesgeorge007
25fd35770a fix: regression with missing lucide icon pack
Add `@iconify-json/lucide` as a dev dependency to all packages that consume `unplugin-icons`.
2024-06-26 11:59:21 +05:30
Andrew Bastin
762cd2207b chore: bump version to 2024.6.0 2024-06-25 21:29:30 +05:30
Andrew Bastin
1da5dde0ba refactor: bump dioc to 3.0.2 2024-06-25 20:54:25 +05:30
Mir Arif Hasan
190a3b8eaf fix: crashes in user last login interceptor (#4146)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2024-06-25 17:15:04 +05:30
Andrew Bastin
aead9e6c98 feat: client certificates and ability to skip ssl cert verification in desktop app (#4111)
Co-authored-by: Nivedin <53208152+nivedin@users.noreply.github.com>
2024-06-25 16:57:07 +05:30
Joel Jacob Stephen
5e3bc01922 refactor(common): improvements to banner handling logic (#4123) 2024-06-25 15:14:52 +05:30
Andrew Bastin
9479258acb chore: merge hoppscotch/patch into hoppscotch/next 2024-06-24 16:09:12 +05:30
Andrew Bastin
7baabebcee chore: merge hoppscotch/main into hoppscotch/patch
there was a hotfix to update the CLI version with a patch release
2024-06-24 16:07:43 +05:30
Andrew Bastin
6e63c723b7 chore: bump cli to 0.8.1 due to new hopp-data schema change 2024-06-24 16:04:39 +05:30
Nivedin
fac90ac342 fix: styling issue on selection in codemirror (#4122) 2024-06-24 12:15:04 +05:30
Anwarul Islam
9d02c8e6c9 feat: added codegen tab in the sidebar (#4099) 2024-06-24 12:03:19 +05:30
Anwarul Islam
d45f15e9cb fix: prevent unwanted variable wrapping during Insomnia import (#4076)
feat: conditionally check path variable
2024-06-24 11:46:50 +05:30
James George
021ecf17ce feat: introduce personal access tokens for authorization (#4094)
Co-authored-by: Nivedin <53208152+nivedin@users.noreply.github.com>
2024-06-24 11:45:31 +05:30
Nivedin
257974325e fix: large content scroll bug in codemirror editor (#4138) 2024-06-21 23:27:22 +05:30
Mir Arif Hasan
c2085b8b6f HSB-450 feature: user last active on (#4121)
* feat: userLastActiveOnInterceptor added and update func added in userService

* chore: changed user model parameter description

* chore: commented out docker volume for hopp-old-service

* chore: changed backend to work with secure cookies

---------

Co-authored-by: Balu Babu <balub997@gmail.com>
2024-06-21 12:40:21 +05:30
Andrew Bastin
cfb77f2bfe chore: merge hoppscotch/patch into hoppscotch/next 2024-06-20 22:26:42 +05:30
Andrew Bastin
292d752f32 chore: update CONTRIBUTING.md 2024-06-17 21:50:10 +05:30
Andrew Bastin
73090c7a2b chore: update PR template 2024-06-17 21:47:59 +05:30
Andrew Bastin
e6cd03c283 chore: update SECURITY.md 2024-06-17 21:41:47 +05:30
Andrew Bastin
cc1be91446 chore: update CODEOWNERS 2024-06-17 21:14:37 +05:30
Andrew Bastin
c2cb295bcb chore: bump version to 2024.3.4 2024-06-14 04:14:16 +05:30
Andrew Bastin
31f1e1b21a fix: headers having different values with the same key are not shown (#4112)
* feat: allow ability for multiple headers with the same key to be shown

* chore: remove extension inspector in selfhost-desktop

* chore: cleanup

---------

Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-06-12 14:11:43 +05:30
Akash K
93807bfe8f fix: make client credential optional in authcode + pkce flow (#4114)
* fix: make client credential optional

* test: update test fixtures

---------

Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-06-12 14:10:54 +05:30
Andrew Bastin
e3ad0c9e2e feat: add ability for platforms to define custom login selector ui 2024-06-11 06:20:02 +05:30
Balu Babu
465ea2b4e0 chore: changed the returned status type for errors in the access-tokens (#4107)
* chore: changed the returned status type for errors in the access-token interceptor

* chore: removed unused pagination args
2024-06-07 12:08:35 +05:30
Alexandre Rodrigues Batista
f13478da86 fix(sh-desktop): add missing @hoppscotch/data to selfhost-desktop (#4097) 2024-06-06 20:37:45 +05:30
Joel Jacob Stephen
5805826994 refactor(sh-admin): improved error handling and dynamic user actions in admin dashboard (#4044)
* feat: new helper functions for better error management

* refactor: new i18n strings

* refactor: better error handling in invite modal and members component

* refactor: better user management

* refactor: better error handling in config handler

* refactor: updated logic of dynamic action row

* refactor: better naming for computed properties

* feat: new error message when an admin tries to invite himself

* refactor: updated error message when user is already invited

* refactor: reverted i18n string for user already invited back to the old string

* refactor: removed show prop from invite modal

* refactor: improved implementation for getting the compiled error messages

* feat: new error message when email inputted is of an invalid format

* refactor: minor optimization

---------

Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-06-03 17:17:46 +05:30
islamzeki
5fd7c28894 locale: update tr i18n translations (#4057)
* Update tr.json

i18n

* Update tr.json

i18n

* Update tr.json

l10n

* Update tr.json

l10n based on en.json

* Update tr.json

* Update tr.json
2024-06-03 14:35:32 +05:30
Joel Jacob Stephen
b601a2f55f fix(sh-admin): saving changes to server configurations post a failed attempt will require a page reload in dashboard (#4081)
* fix: resolved an issue with server restart component

* refactor: early return if any mutation fails when initiating server restart

* fix: ensure further attempts go through after a failed reset configs action

---------

Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-06-03 14:31:44 +05:30
Nivedin
bece13e6b0 fix: precision lost when json is beautified (#4086) 2024-06-03 14:30:31 +05:30
Nivedin
d0350ec789 fix: add previous value as optional in test schema env diff (#4071)
* fix: add previous value as optional

* refactor: remove method chaining for consistency

---------

Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-06-03 14:29:48 +05:30
Nivedin
5c214a8da0 fix: persist interceptor state for logged out user (#4060)
* fix: persist interceptor state for loged out user

* refactor: update interceptor section in settings page

* chore: update settings page interceptor section UI

* chore: minoir ui and code refactor
2024-06-03 14:28:58 +05:30
Gusram
2c0805fafe docs(desktop): update building instruction 2024-05-31 01:41:06 +08:00
Gusram
26b4f64824 chore(desktop): update dependencies version 2024-05-31 01:39:36 +08:00
Gusram
4156551b24 feat(desktop): implement backend wrapper auth 2024-05-31 00:39:19 +08:00
Balu Babu
4bd23a8f4c feat: adding support for hopp-cli in self-host Hoppscotch (#4079)
* feat: created a new table to store user PATs

* chore: renamed UserTokens table name to PersonalAccessToken

* chore: added unique property to token column

* chore: made expiresOn field optional

* chore: created access-token module

* feat: created access-token rest routes

* chore: created a new auth guard for PATs

* chore: scaffolded routes in team collection and environments modules for CLI

* chore: created method to update lastUsedOn property for accessTokens

* chore: created interceptor to update lastUsedOn property of PAT

* feat: interceptor to update lastUpdatedOn property complete

* chore: removed unused imports in access-token interceptor

* chore: moved routes to fetch collection and environment into access-token module

* feat: added routes to fetch collections and environments for CLI

* chore: modified access-token interceptor

* chore: removed log statement from interceptor

* chore: added team member checking logic to ForCLI methods in team collection and environments module

* chore: changed return error messages to those defined in spec

* chore: added comments to all service methods

* chore: removed unused imports

* chore: updated testcases for team-environments module service file

* chore: added and updated testcases

* chore: removed unneseccary SQL from auto-generated migration sql for PAT

* chore: remobed JWTAuthGuard from relevant routes in PAT controllers file

* chore: modified token for auth in PATAuthGuard

* chore: changed error codes in some certain service methods in access-token module

* feat: worked on feedback for PR review

* chore: renamed service method in access-token module

* chore: removed console log statements

* chore: modified cli error type

* test: fix broken test case

* chore: changed target of hopp-old-backend to prod

---------

Co-authored-by: mirarifhasan <arif.ishan05@gmail.com>
2024-05-28 16:39:50 +05:30
Mir Arif Hasan
f4f3fdf2d5 HSB-445 feature: storing user last login timestamp (#4074)
* feat: lastLoggedOn added in schema and service function

* feat: add lastLoggedOn logic for magic link

* test: update test cases

* feat: add lastLoggedOn in gql model

* fix: nullable allowed in model attribute

* fix: resolve feedback

* feat: user last login interceptor added
2024-05-27 21:49:42 +05:30
Mir Arif Hasan
b7a3ae231b HSB-431 fix: email case sensitive in email provider (#4042)
* feat: code level email insensitivity added

* test: fix broken test case

* chore: updated testcase for findUserByEmail in user module

---------

Co-authored-by: Balu Babu <balub997@gmail.com>
2024-05-15 12:37:35 +05:30
Nivedin
f8ac6dfeb1 chore: add workspace switcher login A/B testing flow (#4053)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2024-05-10 16:35:42 +05:30
Andrew Bastin
7d2d335b37 chore: revert back default interceptor for sh app to browser 2024-05-10 16:13:51 +05:30
Andrew Bastin
76875db865 chore: bump selfhost-desktop lockfile version 2024-05-10 15:04:16 +05:30
Balu Babu
96e2d87b57 feat: update node version to node20-apline3.19 (#4040) 2024-05-10 14:24:34 +05:30
islamzeki
be353d9f72 chore(i18n): update tr.json (#4039) 2024-05-10 14:16:04 +05:30
Nivedin
38bc2c12c3 refactor: set default interceptor to "proxy" (#4051) 2024-05-10 14:01:43 +05:30
Stéfany Larissa
97644fa508 chore: update pt-br translations (#4003) 2024-05-10 13:56:58 +05:30
Dmitry
eb3446ae23 locale: update ru locale (#4005)
Co-authored-by: Dmitry Mukovkin <d.mukovkin@cft.ru>
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
Co-authored-by: Joel Jacob Stephen <70131076+JoelJacobStephen@users.noreply.github.com>
2024-05-10 13:51:25 +05:30
Dmitry
6c29961d09 Added new button to save request modal (#3976)
Co-authored-by: Dmitry Mukovkin <d.mukovkin@cft.ru>
Co-authored-by: Nivedin <53208152+nivedin@users.noreply.github.com>
2024-05-09 20:52:22 +05:30
Nivedin
ef1117d8cc refactor: switch workspace after creation (#4015)
Co-authored-by: amk-dev <akash.k.mohan98@gmail.com>
2024-05-09 20:28:24 +05:30
Balu Babu
5c4b651aee feat: healthcheck for external services (#4048)
* chore: installed terminus package and scaffolded the health module

* feat: added healthcheck for database with prisma

* chore: renamed label from prisma to database in health check route

* chore: reverted target of hopp-old-backend service to prod
2024-05-07 20:05:17 +05:30
Andrew Bastin
391e5a20f5 chore: bump versions to 2024.3.3 2024-05-06 22:57:33 +05:30
Andrew Bastin
4b8f3bd8da fix: code generate modal erroring out (#4033) 2024-05-06 22:44:13 +05:30
Joel Jacob Stephen
94248076e6 refactor(sh-admin): improved handling of server configurations in admin dashboard (#3971)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-05-06 21:50:31 +05:30
Nivedin
eecc3db4e9 chore: update placeholder text (#4023) 2024-04-30 16:49:32 +05:30
Andrew Bastin
426e7594f4 fix: tab systems erroring out due to out of sync tabOrdering and tabMap 2024-04-29 20:32:27 +05:30
Andrew Bastin
934dc473f0 chore: bump version to 2024.3.2 2024-04-29 19:21:48 +05:30
Andrew Bastin
be57255bf7 refactor: update to dioc v3 (#4009)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-04-29 19:06:18 +05:30
Balu Babu
f89561da54 fix: resolved mailer module email issue (#4000) 2024-04-29 12:05:07 +05:30
Joel Jacob Stephen
c2c4e620c2 fix(common): rest and graphql pages not being rendered when french is selected as app language (#4004)
Co-authored-by: nivedin <nivedinp@gmail.com>
2024-04-25 18:01:46 +05:30
Andrew Bastin
844eee0fa4 chore: update @hoppscotch/cli README 2024-04-22 21:12:27 +05:30
Andrew Bastin
d21bb65511 chore: bump cli version to 0.8 2024-04-22 20:29:03 +05:30
Andrew Bastin
4f614f7257 chore: bump version to 2024.3.1 2024-04-22 20:26:25 +05:30
Nivedin
0e2887b4e9 feat: first time user spotlight animation (#3977) 2024-04-22 12:21:30 +05:30
Andrew Bastin
18652ce400 fix: update prod.Dockerfile to add additional required deps during base build 2024-04-20 01:54:34 +05:30
Eduardo San Martin Morote
08c655235d fix: use pressed key rather than its code (#3978)
Co-authored-by: Joel Jacob Stephen <70131076+JoelJacobStephen@users.noreply.github.com>
2024-04-19 22:35:13 +05:30
Vincent M
51412549e8 chore(i18n): french lang translation (#3986) 2024-04-19 21:21:15 +05:30
James George
22c6eabd13 chore: migrate Node.js implementation for js-sandbox to isolated-vm (#3973)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2024-04-19 21:08:46 +05:30
Mir Arif Hasan
a079e0f04b refactor: infra-config code refactor (#3982)
* chore: add getMissingInfraConfigEntries to avoid code duplication

* feat: isServiceConfigured modified

* docs: add missing function doc

* feat: argon2 version updated and removed types-argon2 depricated package

* Revert "feat: argon2 version updated and removed types-argon2 depricated package"

This reverts commit b99f3c5aae.

* Revert "feat: isServiceConfigured modified"

This reverts commit eaa6f105bb.
2024-04-19 12:43:43 +05:30
jamesgeorge007
375d53263a test: fix failing tests 2024-04-16 23:55:07 +05:30
Thomas Bonnet
57ef3e085f chore(i18n): Updating the packages/hoppscotch-common/locales/fr.json file. (#3555) 2024-04-16 18:15:38 +05:30
Timotej
9fb6e59e36 fix(desktop): set window caption color if Windows version >= 11 (#3939) 2024-04-16 17:51:26 +05:30
Sawako
1b0802b0e6 fix: spanish lang translation messages (#3950) 2024-04-16 17:47:10 +05:30
krisztianbarat
fb45fe4627 chore(i18n): update locale hu (#3875) 2024-04-16 17:45:55 +05:30
Akash K
0f592d1789 fix: use proper values for addTo field when auth type is api-key (#3966) 2024-04-16 17:44:28 +05:30
jamesgeorge007
787aab650f fix: redirect to the users list view on successful deletion from the profile view
SH Admin dashboard
2024-03-28 21:20:48 +05:30
Anwarul Islam
1f7a8edb14 fix: lint errors removed by using satisfies or as for type (#3934)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
Co-authored-by: amk-dev <akash.k.mohan98@gmail.com>
2024-03-28 20:28:48 +05:30
James George
81f1e05a6c chore(sh-admin): alert the user while deleting users who are team owners (#3937)
Co-authored-by: amk-dev <akash.k.mohan98@gmail.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
2024-03-28 20:17:24 +05:30
James George
0a71783eaa fix(common): ensure requests are translated to the latest version during import and search actions (#3931) 2024-03-25 17:09:54 +05:30
Nivedin
c326f54f7e feat: added mutation and function to platform for updating user profile name (#3929)
Co-authored-by: amk-dev <akash.k.mohan98@gmail.com>
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-25 14:41:25 +05:30
Dmitry
1113c79e20 fix: can't import curl command with data-urlencode (#3905)
Co-authored-by: Anwarul Islam <anwaarulislaam@gmail.com>
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-25 13:12:48 +05:30
Akash K
6fd30f9aca chore: spotlight improvements for team requests search (#3930)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-25 12:41:18 +05:30
Anwarul Islam
2c5b0dcd1b feat: focus codemirror view when ImportCurl component launched (#3911)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-22 18:21:16 +05:30
Anwarul Islam
6f4455ba03 fix: request failing on change content type to multipart-formdata (#3922)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-22 18:11:16 +05:30
Nivedin
ba8c4480d9 fix: workspace list section bugs (#3925)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-22 18:02:28 +05:30
Joel Jacob Stephen
380397cc55 refactor(sh-admin): improvements to pending invites page in dashboard (#3926) 2024-03-22 17:54:40 +05:30
Akash K
d19807b212 chore: split axios request options into platform (#3927)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-22 13:42:41 +05:30
Balu Babu
c89c2a5f5c feat: added new mutation to update username in hopp app (#3924)
* feat: added new mutation to update username in hopp app

* feat: display name length validation added

---------

Co-authored-by: mirarifhasan <arif.ishan05@gmail.com>
2024-03-22 12:07:38 +05:30
Anwarul Islam
256553b9bb chore: update copy for header inspection (#3907)
Co-authored-by: James George <jamesgeorge998001@gmail.com>
2024-03-21 22:15:23 +05:30
Akash K
89d9951f3b fix: fix typo in team search url (#3923)
fix: fix typo in team search endpoint url

Co-authored-by: James George <jamesgeorge998001@gmail.com>
2024-03-21 16:44:58 +05:30
Balu Babu
dd65ad3103 chore: added input validation to search query (#3921) 2024-03-21 16:13:11 +05:30
Balu Babu
018ed3db26 refactor: AIO healthcheck bash script (#3920)
* chore: added logic to make script with with subpath

* chore: removed variable from failed echo message
2024-03-21 16:12:13 +05:30
Andrew Bastin
a9cd6c0c01 chore: update internal deployment docker compose config 2024-03-21 02:38:55 +05:30
James George
e53382666a fix(common): prevent exception with ShortcodeListAdapter initialization (#3917) 2024-03-20 20:29:04 +05:30
James George
7621ff2961 feat: add extended support for versioned entities in the CLI (#3912) 2024-03-20 20:13:22 +05:30
Akash K
fc20b76080 fix: direct import from url failing (#3918) 2024-03-20 20:06:51 +05:30
Nivedin
146c73d7b6 feat: github enterprise SSO provider addition (#3914) 2024-03-20 20:01:56 +05:30
Akash K
6b58915caa feat: oauth revamp + support for multiple grant types in oauth (#3885)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-20 00:18:03 +05:30
Akash K
457857a711 feat: team search in workspace search and spotlight (#3896)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-19 18:50:35 +05:30
Balu Babu
a3f3e3e62d refactor: collection search query (#3908) 2024-03-19 17:12:35 +05:30
Andrew Bastin
66f20d10e1 chore: enable subpath based access in test deploy docker compose 2024-03-19 16:26:04 +05:30
Andrew Bastin
32e9366609 chore: update test deploy docker compose aio port 2024-03-19 15:53:08 +05:30
Andrew Bastin
e41e956273 chore: add test deployment docker compose file 2024-03-19 14:39:57 +05:30
Nivedin
a14870f3f0 fix: collection auth headers active tab update bug and type fix (#3899) 2024-03-15 21:17:34 +05:30
Andrew Bastin
0e96665254 refactor: use trigram search index instead of full text search (#3900)
Co-authored-by: Balu Babu <balub997@gmail.com>
2024-03-15 20:10:12 +05:30
kaifulee
efdc1c2f5d chore: fix some typos (#3895)
Signed-off-by: kaifulee <cuishuang@outlook.com>
2024-03-15 20:06:34 +05:30
Andrew Bastin
c5334d4c06 chore(sh-admin): bump @hoppscotch/ui version to 0.1.3 2024-03-15 12:43:05 +05:30
Balu Babu
4f549974ed fix: reset infra-config bug (#3898) 2024-03-14 21:46:34 +05:30
Nivedin
41d617b507 fix: secret env bug in firebase due to undefined value (#3881)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-13 17:11:51 +05:30
Joel Jacob Stephen
be7387ed19 refactor(sh-admin): updated data sharing doc links + remove disabled property from all inputs in configurations (#3894) 2024-03-13 16:18:27 +05:30
Joel Jacob Stephen
acfb0189df feat(sh-admin): enhanced user management in admin dashboard (#3814)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-13 14:45:13 +05:30
Nivedin
8fdba760a2 refactor: personal workspace nomenclature update (#3893)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-13 14:21:23 +05:30
Nivedin
bf98009abb fix: request variable version syncing bug (#3889)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-12 11:42:05 +05:30
James George
dce396c164 chore: bump codemirror dependencies (#3888) 2024-03-11 14:21:39 +05:30
Nivedin
07e8af7947 refactor: update team nomenclature (#3880)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-08 23:54:32 +05:30
Joel Jacob Stephen
e69d5a6253 feat(sh-admin): introducing additional SSO related server configurations to dashboard (#3737)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-08 15:18:53 +05:30
Akash K
6d66d12a9e feat: common changes for site protection (#3878) 2024-03-07 23:43:20 +05:30
James George
439cd82c88 chore: pin dependencies across packages (#3876) 2024-03-07 23:37:48 +05:30
Akash K
6dbaf524ce feat: use tags as folders when importing from openapi (#3846) 2024-03-07 19:55:46 +05:30
Andrew Bastin
68e439d1a4 chore: bump version to 2024.3.0 2024-03-07 19:22:46 +05:30
Nivedin
8deba7a28e fix: context menu bug and incorrect position (#3874) 2024-03-07 17:59:06 +05:30
Nivedin
7ec8659381 feat: request variables (#3825)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-07 12:50:44 +05:30
Mir Arif Hasan
3611cac241 feat(backend): sso callback url and scope added in infra-config (#3718) 2024-03-07 12:07:51 +05:30
Joel Jacob Stephen
919579b1da feat(sh-admin): introducing data analytics and newsletter configurations (#3845)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
2024-03-06 20:06:48 +05:30
Nivedin
4798d7bbbd refactor: remove restore tab popup and its functionalities (#3867) 2024-03-05 18:14:41 +05:30
Balu Babu
a0c6b22641 feat: full text search for TeamCollections and TeamRequests (#3857)
Co-authored-by: mirarifhasan <arif.ishan05@gmail.com>
2024-03-05 18:05:58 +05:30
James George
de8929ab18 feat(common): support simultaneous imports of collections and environment files (#3719) 2024-03-05 17:49:01 +05:30
Andrew Bastin
55a94bdccc chore: merge hoppscotch/release/2023.12.6 into hoppscotch/release/2024.3.0 2024-02-27 13:35:20 +05:30
Andrew Bastin
faab1d20fd chore: bump version to 2023.12.6 2024-02-26 22:31:58 +05:30
Anwarul Islam
bd406616ec fix: collection level authorization inheritance issue (#3852) 2024-02-23 19:39:55 +05:30
Andrew Bastin
6827e97ec5 refactor: possible links in email templates do not highlight (#3851) 2024-02-23 01:05:20 +05:30
amk-dev
10d2048975 fix: use x-www-form-urlencoded for token exchange requests 2024-02-22 00:43:50 +05:30
Nivedin
291f18591e fix: perfomance in safari (#3848) 2024-02-22 00:41:30 +05:30
James George
342532c9b1 fix(common): prevent exceptions with open shared requests in new tab action (#3835) 2024-02-22 00:36:45 +05:30
Balu Babu
cf039c482a feat: SH instance analytics data collection (#3838) 2024-02-22 00:35:12 +05:30
Mir Arif Hasan
ded2725116 feat: admin user management (backend) (#3786) 2024-02-21 21:35:08 +05:30
Balu Babu
9c6754c70f feat: inital setup info route (#3847) 2024-02-21 21:15:47 +05:30
James George
4bd54b12cd fix(persistence-service): add fallbacks for environments related schemas (#3832) 2024-02-15 23:38:56 +05:30
Andrew Bastin
ed6e9b6954 chore: bump version to 2023.12.5 2024-02-15 21:47:58 +05:30
James George
dfdd44b4ed fix(persistence-service): update global environment variables schema (#3829) 2024-02-15 21:40:31 +05:30
Akash K
fc34871dae fix: accessing undefined property variables (#3831) 2024-02-15 21:32:50 +05:30
Nivedin
45b532747e fix: environment tooltip update bug (#3819) 2024-02-13 17:42:02 +05:30
Akash K
de4635df23 chore: add workspace type property in request run analytics event (#3820)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2024-02-13 17:38:11 +05:30
Nivedin
41bad1f3dc fix: secret environment flow bugs (#3817) 2024-02-10 20:22:10 +05:30
Andrew Bastin
ecca3d2032 chore: correct linting errors 2024-02-09 14:42:12 +05:30
Muhammed Ajmal M
47226be6d0 feat: persist line wrap setting (#3647)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-02-09 14:05:09 +05:30
Andrew Bastin
6a0e73fdec chore: bump versions 2024-02-08 22:41:59 +05:30
Andrew Bastin
672ee69b2c chore: correct linting errors 2024-02-08 22:33:03 +05:30
Joel Jacob Stephen
b359650d96 refactor: updated teams nomenclature in admin dashboard to workspaces (#3770) 2024-02-08 22:17:42 +05:30
James George
c0fae79678 fix(sh-admin): persist active selection in the sidebar (#3812) 2024-02-08 22:16:33 +05:30
James George
5bcc38e36b feat: support secret environment variables in CLI (#3815) 2024-02-08 22:08:18 +05:30
Nivedin
00862eb192 feat: secret variables in environments (#3779)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-02-08 21:58:42 +05:30
Akash K
16803acb26 chore: Oauth temporary ux improvements (#3792) 2024-02-06 20:35:29 +05:30
Nivedin
3911c9cd1f refactor: update share request flow (#3805) 2024-02-05 23:50:15 +05:30
Florian Metz
0028f6e878 feat(js-sandbox): expose atob & btoa functions for Node.js (#3724)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-02-05 23:12:55 +05:30
Anwarul Islam
0ba33ec187 fix: request endpoint heading (#3804) 2024-02-05 23:08:16 +05:30
James George
3482743782 chore(cli): emit bundle in ESM format (#3777) 2024-02-05 22:55:05 +05:30
James George
d7cdeb796a chore(common): analytics on spotlight (#3727)
Co-authored-by: amk-dev <akash.k.mohan98@gmail.com>
2024-02-02 15:32:06 +05:30
Joel Jacob Stephen
3d6adcc39d refactor: consolidated admin dashboard improvements (#3790)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-02-02 15:17:25 +05:30
Andrew Bastin
aab76f1358 chore: bump version to 2023.12.3 2024-01-30 20:27:25 +05:30
Anwarul Islam
a28a576c41 feat: team environment search and switch (#3700)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-01-30 19:49:04 +05:30
Liyas Thomas
0d0ad7a2f8 chore: added micro interactions (#3783)
Co-authored-by: Dmitry <mukovkin@yandex.ru>
2024-01-30 18:42:42 +05:30
Balu Babu
1df9de44b7 chore: upgraded prisma version to v5.8.0 (#3787) 2024-01-30 18:16:28 +05:30
Muhammed Ajmal M
4cba03e53f feat(js-sandbox): add pw.env.unset method (#3677)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-01-23 22:31:27 +05:30
Nivedin
9e1466a877 fix: bugs in shared request (#3704) 2024-01-23 22:24:18 +05:30
Anwarul Islam
b81ccb4ee3 fix: tab on current input field to focus the next input field (#3754) 2024-01-23 22:21:23 +05:30
Nivedin
27d0a7c437 refactor: persist running requests while switching tabs (#3742) 2024-01-23 22:13:57 +05:30
Nivedin
aca96dd5f2 refactor: add option to disable context menu (#3717) 2024-01-23 22:05:05 +05:30
Anwarul Islam
c0dbcc901f fix: documentation is not being generated on GQL (#3730)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2024-01-23 22:00:41 +05:30
Joel Jacob Stephen
ba52c8cc37 refactor: improvements to the dashboard sidebar (#3709)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-01-23 21:55:42 +05:30
Dmitry
d1f6f40ef8 fix: perform logout if the silent refresh attempt fails (#3705)
Co-authored-by: Dmitry Mukovkin <d.mukovkin@cft.ru>
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-01-23 21:53:59 +05:30
Akash K
99f5070f71 fix: unwanted double slashes when importing from openapi (#3745) 2024-01-23 21:49:34 +05:30
Andrew Bastin
cd371fc9d4 chore: bump versions to 2023.12.2 2024-01-03 16:58:51 +05:30
Jordi Been
59fef248c0 build: update node alpine version (#3660) 2024-01-03 16:49:51 +05:30
Andrew Bastin
286fcd2bb0 chore: bump selfhost-desktop version to 23.12.1-1 2023-12-24 13:45:41 +05:30
Andrew Bastin
b2d98f7b66 chore: remove windi from selfhost-desktop 2023-12-24 13:21:12 +05:30
James George
c6c220091a feat(common): support importing environments individually (#3691) 2023-12-24 12:12:02 +05:30
Andrew Bastin
8f503479b6 chore: bump package version to 2023.12.1 2023-12-22 20:49:21 +05:30
Mir Arif Hasan
54d8378ccf fix: improve smtp email validation and fix enableAndDisableSSO mutation (#3689)
Co-authored-by: Balu Babu <balub997@gmail.com>
2023-12-22 20:37:15 +05:30
James George
0df194f9c5 fix(cli): environment resolution in the single-entry export format (#3687) 2023-12-22 19:21:33 +05:30
Liyas Thomas
ddf7eb6ad6 chore: minor ui improvements 2023-12-20 18:30:16 +05:30
Nivedin
7db7b9b068 fix: fallback section for embeds if invalid link (#3673) 2023-12-19 18:37:44 +05:30
James George
3d25ef48d1 fix(persistence-service): update schemas found to differ in runtime (#3671) 2023-12-19 18:34:27 +05:30
Mir Arif Hasan
4f138beb8a chore: db migration missing message (#3672) 2023-12-19 18:42:00 +06:00
James George
3d7a76bced chore(common): Gist export flow updates (#3665) 2023-12-19 17:37:35 +05:30
Nivedin
74359ea74e fix: auth-header not inheriting properties (#3668) 2023-12-19 17:04:24 +05:30
Mir Arif Hasan
a694d3f7eb hotfix: added validation on infra config update (#3667)
* feat: added validation on infra config update

* chore: removed async keyword

* fix: feedback
2023-12-19 17:15:46 +06:00
Nivedin
58a9514b67 fix: gql history schema error (#3662) 2023-12-19 16:39:32 +05:30
Akash K
a75bfa9d9e fix: actions not working when sidebar is hidden (#3669) 2023-12-19 16:13:59 +05:30
Andrew Bastin
7374a35b41 fix: broken ui due to accidentally moved postcss config 2023-12-19 12:40:07 +05:30
Andrew Bastin
5ad8f6c2ce chore: move window.open to platform io to handle desktop app 2023-12-19 11:26:37 +05:30
Andrew Bastin
f28298afe7 chore: update desktop app version 2023-12-18 23:43:40 +05:30
Andrew Bastin
56c6e8c643 chore: add devtools for production desktop app builds 2023-12-18 23:43:40 +05:30
Andrew Bastin
1b36de4fa3 fix: handle backspace navigating back on desktop app 2023-12-18 23:43:40 +05:30
Andrew Bastin
2f773bec79 fix: drag not working on windows 2023-12-18 23:43:40 +05:30
Andrew Bastin
d3e04c59cc chore: update tailwind stuff on selfhost-desktop 2023-12-18 23:43:40 +05:30
James George
5179cf59a4 fix(common): ensure the add-environment modal value field is empty when opened via the inspector (#3664) 2023-12-18 20:39:23 +05:30
Andrew Bastin
fad31a47ee chore: bump versions of all relevant packages 2023-12-16 22:27:41 +05:30
James George
72c71ddbd4 chore: remove expand widget from the GQL collections import/export modal (#3661) 2023-12-16 17:06:51 +05:30
Nivedin
a0f5ebee39 fix: embeds system theme (#3659)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-12-16 17:01:23 +05:30
Anwarul Islam
f93558324f chore: move hoppscotch-ui package out of the monorepo (#3620)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-12-16 16:58:10 +05:30
Liyas Thomas
d80e6c01c8 chore: i18n 2023-12-16 12:29:44 +05:30
Liyas Thomas
06f0f1c91b fix: broken docs link 2023-12-16 10:36:54 +05:30
Joel Jacob Stephen
9b870f876a chore: banner cleanup (#3658) 2023-12-15 17:59:33 +05:30
Joel Jacob Stephen
cf8b5975ac refactor: improvements made to how banners are to be dismissed (#3656) 2023-12-15 17:08:57 +05:30
Nivedin
93082c3816 refactor: embeds preview theme (#3657) 2023-12-15 17:08:02 +05:30
James George
d66537ac34 fix(common): GraphQL query syntax highlighting (#3653)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-12-15 14:37:01 +05:30
Nivedin
fc4c15e52d fix: auth-headers in collection bug (#3652)
* fix: fallback for importers and fix spelling

* chore: update i18n strings
2023-12-15 02:49:35 +05:30
Liyas Thomas
b521604b66 fix: collection properties styles 2023-12-14 18:00:46 +05:30
James George
9bc81a6d67 feat(cli): support collection level authorization and headers (#3636) 2023-12-14 12:43:22 +05:30
Joel Jacob Stephen
c47e2e7767 fix: notify that the user is not an admin when trying to login with a non admin account in admin dashboard (#3651) 2023-12-14 12:36:05 +05:30
Akash K
5209c0a8ca feat: dynamically load enabled auth providers (#3646) 2023-12-13 23:38:21 +05:30
Nivedin
47e009267b feat: collection level headers and authorization (#3505)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-12-13 22:43:18 +05:30
Joel Jacob Stephen
f3edd001d7 feat: introducing server configurations in admin dashboard (#3628)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-12-13 22:34:59 +05:30
Akash K
a8cc569786 feat: import environments from insomnia (#3625)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-12-13 19:15:39 +05:30
Liyas Thomas
3ae49ca483 fix: codemirror tooltip margin 2023-12-13 12:13:34 +05:30
Liyas Thomas
37e6497e88 fix: z-index 2023-12-13 11:02:56 +05:30
Liyas Thomas
b522ae9e05 fix: shortcut key size 2023-12-13 10:17:22 +05:30
Liyas Thomas
62b11fcec8 fix: z-index on toast 2023-12-13 09:22:58 +05:30
Liyas Thomas
51ebb57623 fix: z-index 2023-12-13 08:45:30 +05:30
Liyas Thomas
ff5c2ba51c chore: minor ui improvements to modal 2023-12-12 23:32:42 +05:30
Mir Arif Hasan
6abc0e6071 HBE-326 feature: server configuration through GraphQL API (#3591)
* feat: restart cmd added in aio service

* feat: nestjs config package added

* test: fix all broken test case

* feat: infra config module add with get-update-reset functionality

* test: fix test case failure

* feat: update infra configs mutation added

* feat: utilise ConfigService in util functions

* chore: remove saml stuff

* feat: removed saml stuffs

* fix: config service precedence

* fix: mailer module init with right env value

* feat: added mutations and query

* feat: add query infra-configs

* fix: mailer module init issue

* chore: smtp url validation added

* fix: all sso disabling is handled

* fix: pnpm i without db connection

* fix: allowedAuthProviders and enableAndDisableSSO

* fix: validateSMTPUrl check

* feat: get api added for fetch provider list

* feat: feedback resolve

* chore: update code comments

* fix: uppercase issue of VITE_ALLOWED_AUTH_PROVIDERS

* chore: update lockfile

* fix: add validation checks for MAILER_ADDRESS_FROM

* test: fix test case

* chore: feedback resolve

* chore: renamed an enum

* chore: app shutdown way changed

---------

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-12-12 16:42:58 +06:00
Andrew Bastin
957641fb0f chore: move dioc out of monorepo 2023-12-12 15:39:10 +05:30
Liyas Thomas
a55f214102 fix: use base url instead of hardcoded url (#3635) 2023-12-12 15:06:28 +05:30
Liyas Thomas
ebf90207e5 chore: improve placeholder component styles (#3638)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-12-12 15:02:42 +05:30
Liyas Thomas
4ac8a117ef chore: add protocol logos to realtime page (#3637) 2023-12-12 14:25:56 +05:30
Muhammed Ajmal M
c1bc430ee6 fix: overflowing modal fix on small screens (#3643)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-12-12 14:21:19 +05:30
James George
9201aa7d7d fix(common): ensure banner colors are displayed correctly (#3630) 2023-12-12 13:55:18 +05:30
Liyas Thomas
87395a4553 fix: minor ui issues 2023-12-07 17:12:33 +05:30
Andrew Bastin
6063c633ee chore: pin vue workspace-wide to 3.3.9 2023-12-07 17:04:17 +05:30
Akash K
7481feb366 feat: introduce platform defs for adding additional spotlight searchers (#3631) 2023-12-07 16:28:02 +05:30
James George
bdfa14fa54 refactor(scripting-revamp): migrate js-sandbox to web worker/Node vm based implementation (#3619) 2023-12-07 16:10:42 +05:30
Andrew Bastin
0a61ec2bfe fix: useI18n type breaking 2023-12-07 15:17:41 +05:30
Nivedin
2bf0106aa2 feat: embeds (#3627)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-12-07 15:03:49 +05:30
Akash K
ab7c29d228 refactor: revamp the importers & exporters systems to be reused (#3425)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-12-06 21:24:29 +05:30
Joel Jacob Stephen
d9c75ed79e feat: introducing shared requests to admin dashboard (#3537)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-12-06 00:21:28 +05:30
Anwarul Islam
6fa722df7b chore: hoppscotch-ui improvements (#3497)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-12-06 00:08:44 +05:30
Balu Babu
18864bfecf feat: addition of data field into User and Team Collections (#3614)
* feat: added new columns into the TeamCollections and UserCollections models

* feat: completed addition of new data field in TeamCollection

* feat: completed addition of new data field in UserCollections

* chore: updated all tests in team-collection module

* chore: added tests for updateTeamCollection method in team-collection module

* chore: refactored all existing testcases in user-collection to reflect new changes

* chore: added new testcases for updateUserCollection method in user-collection module

* chore: made data field optional in team and user collections

* chore: fixed edgecases for data being null

* chore: resolved issue with team-request testcases

* chore: completed changes requested in PR review

* chore: changed target to prod in hoppscotch-old-backend service
2023-12-05 20:12:37 +05:30
Akash K
95754cb2b4 chore: bump deps for hoppscotch-common and hoppscotch-selfhost-web (#3575) 2023-12-05 17:33:25 +05:30
Gaurav K P
ed2a461dc5 feat(common): display status text from the API response if available (#3466)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-12-04 23:31:49 +05:30
Muhammed Ajmal M
8d5a456dbd fix(common): parentheses and single quotes support to curl imports (#3509) 2023-12-04 23:03:22 +05:30
Nivedin
2528bbb92f feat: shared request (#3486)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-12-04 22:51:18 +05:30
Liyas Thomas
259cd48dbb feat: init boring avatars (#3615) 2023-12-04 13:20:26 +05:30
Joel Jacob Stephen
b43531f200 feat: add ability to make banners dismissible + new info and warning color schemes added based on themes (#3586)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-12-04 00:57:37 +05:30
Muhammed Ajmal M
26da3e18a9 fix(common): prevented truncating parameters (#3502) 2023-12-04 00:49:45 +05:30
Rajdip Bhattacharya
bb4b640e58 feat: convert json to interfaces (#3566)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
2023-12-03 23:14:26 +05:30
899 changed files with 72664 additions and 42581 deletions

View File

@@ -9,6 +9,9 @@ MAGIC_LINK_TOKEN_VALIDITY= 3
REFRESH_TOKEN_VALIDITY="604800000" # Default validity is 7 days (604800000 ms) in ms
ACCESS_TOKEN_VALIDITY="86400000" # Default validity is 1 day (86400000 ms) in ms
SESSION_SECRET='add some secret here'
# Reccomended to be true, set to false if you are using http
# Note: Some auth providers may not support http requests
ALLOW_SECURE_COOKIES=true
# Hoppscotch App Domain Config
REDIRECT_URL="http://localhost:3000"
@@ -35,9 +38,20 @@ MICROSOFT_SCOPE="user.read"
MICROSOFT_TENANT="common"
# Mailer config
MAILER_SMTP_URL="smtps://user@domain.com:pass@smtp.domain.com"
MAILER_SMTP_ENABLE="true"
MAILER_USE_CUSTOM_CONFIGS="false"
MAILER_ADDRESS_FROM='"From Name Here" <from@example.com>'
MAILER_SMTP_URL="smtps://user@domain.com:pass@smtp.domain.com" # used if custom mailer configs is false
# The following are used if custom mailer configs is true
MAILER_SMTP_HOST="smtp.domain.com"
MAILER_SMTP_PORT="587"
MAILER_SMTP_SECURE="true"
MAILER_SMTP_USER="user@domain.com"
MAILER_SMTP_PASSWORD="pass"
MAILER_TLS_REJECT_UNAUTHORIZED="true"
# Rate Limit Config
RATE_LIMIT_TTL=60 # In seconds
RATE_LIMIT_MAX=100 # Max requests per IP
@@ -47,6 +61,7 @@ RATE_LIMIT_MAX=100 # Max requests per IP
# Base URLs
VITE_BACKEND_LOGIN_API_URL=http://localhost:5444
VITE_BASE_URL=http://localhost:3000
VITE_SHORTCODE_BASE_URL=http://localhost:3000
VITE_ADMIN_URL=http://localhost:3100

View File

@@ -7,20 +7,15 @@ Please make sure that the pull request is limited to one type (docs, feature, et
<!-- If this pull request closes an issue, please mention the issue number below -->
Closes # <!-- Issue # here -->
### Description
<!-- Add a brief description of the pull request -->
<!-- Add an introduction into what this PR tries to solve in a couple of sentences -->
### What's changed
<!-- Describe point by point the different things you have changed in this PR -->
<!-- You can also choose to add a list of changes and if they have been completed or not by using the markdown to-do list syntax
- [ ] Not Completed
- [x] Completed
-->
### Checks
<!-- Make sure your pull request passes the CI checks and do check the following fields as needed - -->
- [ ] My pull request adheres to the code style of this project
- [ ] My code requires changes to the documentation
- [ ] I have updated the documentation as required
- [ ] All the tests have passed
### Additional Information
<!-- Any additional information like breaking changes, dependencies added, screenshots, comparisons between new and old behaviour, etc. -->
### Notes to reviewers
<!-- Any information you feel the reviewer should know about when reviewing your PR -->

View File

@@ -17,22 +17,21 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup environment
run: mv .env.example .env
- name: Setup node
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
uses: pnpm/action-setup@v3
with:
version: 8
run_install: true
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Run tests
run: pnpm test

5
.gitignore vendored
View File

@@ -81,10 +81,7 @@ web_modules/
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.env.*
# parcel-bundler cache (https://parceljs.org/)
.cache

1
.npmrc
View File

@@ -1 +1,2 @@
shamefully-hoist=false
save-prefix=''

View File

@@ -1,30 +1,21 @@
# CODEOWNERS is prioritized from bottom to top
# If none of the below matched
* @AndrewBastin @liyasthomas
# Packages
/packages/codemirror-lang-graphql/ @AndrewBastin
/packages/hoppscotch-cli/ @AndrewBastin
/packages/hoppscotch-common/ @amk-dev @AndrewBastin
/packages/hoppscotch-cli/ @jamesgeorge007
/packages/hoppscotch-data/ @AndrewBastin
/packages/hoppscotch-js-sandbox/ @AndrewBastin
/packages/hoppscotch-ui/ @anwarulislam
/packages/hoppscotch-web/ @amk-dev
/packages/hoppscotch-selfhost-web/ @amk-dev
/packages/hoppscotch-js-sandbox/ @jamesgeorge007
/packages/hoppscotch-selfhost-web/ @jamesgeorge007
/packages/hoppscotch-selfhost-desktop/ @AndrewBastin
/packages/hoppscotch-sh-admin/ @JoelJacobStephen
/packages/hoppscotch-backend/ @ankitsridhar16 @balub
/packages/hoppscotch-backend/ @balub
# Sections within Hoppscotch Common
/packages/hoppscotch-common/src/components @anwarulislam
/packages/hoppscotch-common/src/components/collections @nivedin @amk-dev
/packages/hoppscotch-common/src/components/environments @nivedin @amk-dev
/packages/hoppscotch-common/src/composables @amk-dev
/packages/hoppscotch-common/src/modules @AndrewBastin @amk-dev
/packages/hoppscotch-common/src/pages @AndrewBastin @amk-dev
/packages/hoppscotch-common/src/newstore @AndrewBastin @amk-dev
# READMEs and other documentation files
*.md @liyasthomas
README.md @liyasthomas
# The lockfile has no owner
pnpm-lock.yaml
# Self Host deployment related files
*.Dockerfile @balub
docker-compose.yml @balub
docker-compose.deploy.yml @balub
*.Caddyfile @balub
.dockerignore @balub

View File

@@ -11,7 +11,4 @@ Please note we have a code of conduct, please follow it in all your interactions
build.
2. Update the README.md with details of changes to the interface, this includes new environment
variables, exposed ports, useful file locations and container parameters.
3. Increase the version numbers in any examples files and the README.md to the new version that this
Pull Request would represent. The versioning scheme we use is [SemVer](https://semver.org).
4. You may merge the Pull Request once you have the sign-off of two other developers, or if you
do not have permission to do that, you may request the second reviewer merge it for you.
3. Make sure you do not expose environment variables or other sensitive information in your PR.

View File

@@ -239,7 +239,7 @@ Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) f
📦 **Add-ons:** Official add-ons for hoppscotch.
- **[Hoppscotch CLI](https://github.com/hoppscotch/hopp-cli)** - Command-line interface for Hoppscotch.
- **[Hoppscotch CLI](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-cli)** - Command-line interface for Hoppscotch.
- **[Proxy](https://github.com/hoppscotch/proxyscotch)** - A simple proxy server created for Hoppscotch.
- **[Browser Extensions](https://github.com/hoppscotch/hoppscotch-extension)** - Browser extensions that enhance your Hoppscotch experience.

View File

@@ -4,19 +4,36 @@ This document outlines security procedures and general policies for the Hoppscot
- [Security Policy](#security-policy)
- [Reporting a security vulnerability](#reporting-a-security-vulnerability)
- [What is not a valid vulnerability](#what-is-not-a-valid-vulnerability)
- [Incident response process](#incident-response-process)
## Reporting a security vulnerability
Report security vulnerabilities by emailing the Hoppscotch Support team at support@hoppscotch.io.
We use [Github Security Advisories](https://github.com/hoppscotch/hoppscotch/security/advisories) to manage vulnerability reports and collaboration.
Someone from the Hoppscotch team shall report to you within 48 hours of the disclosure of the vulnerability in GHSA. If no response was received, please reach out to
Hoppscotch Support at support@hoppscotch.io along with the GHSA advisory link.
The primary security point of contact from Hoppscotch Support team will acknowledge your email within 48 hours, and will send a more detailed response within 48 hours indicating the next steps in handling your report. After the initial reply to your report, the security team will endeavor to keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
> NOTE: Since we have multiple open source components, Advisories may move into the relevant repo (for example, an XSS in a UI component might be part of [`@hoppscotch/ui`](https://github.com/hoppscotch/ui)).
> If in doubt, open your report in `hoppscotch/hoppscotch` GHSA.
**Do not create a GitHub issue ticket to report a security vulnerability.**
**Do not create a GitHub issue ticket to report a security vulnerability!**
The Hoppscotch team and community take all security vulnerability reports in Hoppscotch seriously. Thank you for improving the security of Hoppscotch. We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions.
The Hoppscotch team takes all security vulnerability reports in Hoppscotch seriously. We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions.
Report security bugs in third-party modules to the person or team maintaining the module.
## What is not a valid vulnerability
We receive many reports about different sections of the Hoppscotch platform. Hence, we have a fine line we have drawn defining what is considered valid vulnerability.
Please refrain from opening an advisory if it describes the following:
- A vulnerability in a dependency of Hoppscotch (unless you have practical attack with it on the Hoppscotch codebase)
- Reports of vulnerabilities related to old runtimes (like NodeJS) or container images used by the codebase
- Vulnerabilities present when using Hoppscotch in anything other than the defined minimum requirements that Hoppscotch supports.
Hoppscotch Team ensures security support for:
- Modern Browsers (Chrome/Firefox/Safari/Edge) with versions up to 1 year old.
- Windows versions on or above Windows 10 on Intel and ARM.
- macOS versions dating back up to 2 years on Intel and Apple Silicon.
- Popular Linux distributions with up-to-date packages with preference to x86/64 CPUs.
- Docker/OCI Runtimes (preference to Docker and Podman) dating back up to 1 year.
## Incident response process

48
docker-compose.deploy.yml Normal file
View File

@@ -0,0 +1,48 @@
# THIS IS NOT TO BE USED FOR PERSONAL DEPLOYMENTS!
# Internal Docker Compose Image used for internal testing deployments
version: "3.7"
services:
hoppscotch-db:
image: postgres:15
user: postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: testpass
POSTGRES_DB: hoppscotch
healthcheck:
test:
[
"CMD-SHELL",
"sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'"
]
interval: 5s
timeout: 5s
retries: 10
hoppscotch-aio:
container_name: hoppscotch-aio
build:
dockerfile: prod.Dockerfile
context: .
target: aio
environment:
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch
- ENABLE_SUBPATH_BASED_ACCESS=true
env_file:
- ./.env
depends_on:
hoppscotch-db:
condition: service_healthy
command: ["sh", "-c", "pnpm exec prisma migrate deploy && node /usr/src/app/aio_run.mjs"]
healthcheck:
test:
- CMD
- curl
- '-f'
- 'http://localhost:80'
interval: 2s
timeout: 10s
retries: 30

View File

@@ -66,6 +66,7 @@ services:
# The service that spins up all 3 services at once in one container
hoppscotch-aio:
container_name: hoppscotch-aio
restart: unless-stopped
build:
dockerfile: prod.Dockerfile
context: .
@@ -99,7 +100,7 @@ services:
test:
[
"CMD-SHELL",
"sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'"
"sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'",
]
interval: 5s
timeout: 5s

View File

@@ -9,6 +9,10 @@ curlCheck() {
fi
}
curlCheck "http://localhost:3000"
curlCheck "http://localhost:3100"
curlCheck "http://localhost:3170/ping"
if [ "$ENABLE_SUBPATH_BASED_ACCESS" = "true" ]; then
curlCheck "http://localhost:80/backend/ping"
else
curlCheck "http://localhost:3000"
curlCheck "http://localhost:3100"
curlCheck "http://localhost:3170/ping"
fi

View File

@@ -23,18 +23,22 @@
"./packages/*"
],
"devDependencies": {
"@commitlint/cli": "^16.2.3",
"@commitlint/config-conventional": "^16.2.1",
"@commitlint/cli": "16.3.0",
"@commitlint/config-conventional": "16.2.4",
"@hoppscotch/ui": "0.2.0",
"@types/node": "17.0.27",
"cross-env": "^7.0.3",
"http-server": "^14.1.1",
"husky": "^7.0.4",
"cross-env": "7.0.3",
"http-server": "14.1.1",
"husky": "7.0.4",
"lint-staged": "12.4.0"
},
"pnpm": {
"overrides": {
"vue": "3.3.9"
},
"packageExtensions": {
"httpsnippet@^3.0.1": {
"peerDependencies": {
"@hoppscotch/httpsnippet": {
"dependencies": {
"ajv": "6.12.3"
}
}

View File

@@ -17,16 +17,16 @@
"types": "dist/index.d.ts",
"sideEffects": false,
"dependencies": {
"@codemirror/language": "6.9.0",
"@lezer/highlight": "1.1.4",
"@lezer/lr": "^1.3.13"
"@codemirror/language": "6.10.1",
"@lezer/highlight": "1.2.0",
"@lezer/lr": "1.3.14"
},
"devDependencies": {
"@lezer/generator": "^1.5.1",
"mocha": "^9.2.2",
"rollup": "^3.29.3",
"rollup-plugin-dts": "^6.0.2",
"rollup-plugin-ts": "^3.4.5",
"typescript": "^5.2.2"
"@lezer/generator": "1.5.1",
"mocha": "9.2.2",
"rollup": "3.29.4",
"rollup-plugin-dts": "6.0.2",
"rollup-plugin-ts": "3.4.5",
"typescript": "5.2.2"
}
}
}

View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,141 +0,0 @@
# dioc
A small and lightweight dependency injection / inversion of control system.
### About
`dioc` is a really simple **DI/IOC** system where you write services (which are singletons per container) that can depend on each other and emit events that can be listened upon.
### Demo
```ts
import { Service, Container } from "dioc"
// Here is a simple service, which you can define by extending the Service class
// and providing an ID static field (of type string)
export class PersistenceService extends Service {
// This should be unique for each container
public static ID = "PERSISTENCE_SERVICE"
public read(key: string): string | undefined {
// ...
}
public write(key: string, value: string) {
// ...
}
}
type TodoServiceEvent =
| { type: "TODO_CREATED"; index: number }
| { type: "TODO_DELETED"; index: number }
// Services have a built in event system
// Define the generic argument to say what are the possible emitted values
export class TodoService extends Service<TodoServiceEvent> {
public static ID = "TODO_SERVICE"
// Inject persistence service into this service
private readonly persistence = this.bind(PersistenceService)
public todos = []
// Service constructors cannot have arguments
constructor() {
super()
this.todos = JSON.parse(this.persistence.read("todos") ?? "[]")
}
public addTodo(text: string) {
// ...
// You can access services via the bound fields
this.persistence.write("todos", JSON.stringify(this.todos))
// This is how you emit an event
this.emit({
type: "TODO_CREATED",
index,
})
}
public removeTodo(index: number) {
// ...
this.emit({
type: "TODO_DELETED",
index,
})
}
}
// Services need a container to run in
const container = new Container()
// You can initialize and get services using Container#bind
// It will automatically initialize the service (and its dependencies)
const todoService = container.bind(TodoService) // Returns an instance of TodoService
```
### Demo (Unit Test)
`dioc/testing` contains `TestContainer` which lets you bind mocked services to the container.
```ts
import { TestContainer } from "dioc/testing"
import { TodoService, PersistenceService } from "./demo.ts" // The above demo code snippet
import { describe, it, expect, vi } from "vitest"
describe("TodoService", () => {
it("addTodo writes to persistence", () => {
const container = new TestContainer()
const writeFn = vi.fn()
// The first parameter is the service to mock and the second parameter
// is the mocked service fields and functions
container.bindMock(PersistenceService, {
read: () => undefined, // Not really important for this test
write: writeFn,
})
// the peristence service bind in TodoService will now use the
// above defined mocked implementation
const todoService = container.bind(TodoService)
todoService.addTodo("sup")
expect(writeFn).toHaveBeenCalledOnce()
expect(writeFn).toHaveBeenCalledWith("todos", JSON.stringify(["sup"]))
})
})
```
### Demo (Vue)
`dioc/vue` contains a Vue Plugin and a `useService` composable that allows Vue components to use the defined services.
In the app entry point:
```ts
import { createApp } from "vue"
import { diocPlugin } from "dioc/vue"
const app = createApp()
app.use(diocPlugin, {
container: new Container(), // You can pass in the container you want to provide to the components here
})
```
In your Vue components:
```vue
<script setup>
import { TodoService } from "./demo.ts" // The above demo
import { useService } from "dioc/vue"
const todoService = useService(TodoService) // Returns an instance of the TodoService class
</script>
```

View File

@@ -1,2 +0,0 @@
export { default } from "./dist/main.d.ts"
export * from "./dist/main.d.ts"

View File

@@ -1,147 +0,0 @@
import { Service } from "./service"
import { Observable, Subject } from 'rxjs'
/**
* Stores the current container instance in the current operating context.
*
* NOTE: This should not be used outside of dioc library code
*/
export let currentContainer: Container | null = null
/**
* The events emitted by the container
*
* `SERVICE_BIND` - emitted when a service is bound to the container directly or as a dependency to another service
* `SERVICE_INIT` - emitted when a service is initialized
*/
export type ContainerEvent =
| {
type: 'SERVICE_BIND';
/** The Service ID of the service being bounded (the dependency) */
boundeeID: string;
/**
* The Service ID of the bounder that is binding the boundee (the dependent)
*
* NOTE: This will be undefined if the service is bound directly to the container
*/
bounderID: string | undefined
}
| {
type: 'SERVICE_INIT';
/** The Service ID of the service being initialized */
serviceID: string
}
/**
* The dependency injection container, allows for services to be initialized and maintains the dependency trees.
*/
export class Container {
/** Used during the `bind` operation to detect circular dependencies */
private bindStack: string[] = []
/** The map of bound services to their IDs */
protected boundMap = new Map<string, Service<unknown>>()
/** The RxJS observable representing the event stream */
protected event$ = new Subject<ContainerEvent>()
/**
* Returns whether a container has the given service bound
* @param service The service to check for
*/
public hasBound<
T extends typeof Service<any> & { ID: string }
>(service: T): boolean {
return this.boundMap.has(service.ID)
}
/**
* Returns the service bound to the container with the given ID or if not found, undefined.
*
* NOTE: This is an advanced method and should not be used as much as possible.
*
* @param serviceID The ID of the service to get
*/
public getBoundServiceWithID(serviceID: string): Service<unknown> | undefined {
return this.boundMap.get(serviceID)
}
/**
* Binds a service to the container. This is equivalent to marking a service as a dependency.
* @param service The class reference of a service to bind
* @param bounder The class reference of the service that is binding the service (if bound directly to the container, this should be undefined)
*/
public bind<T extends typeof Service<any> & { ID: string }>(
service: T,
bounder: ((typeof Service<T>) & { ID: string }) | undefined = undefined
): InstanceType<T> {
// We need to store the current container in a variable so that we can restore it after the bind operation
const oldCurrentContainer = currentContainer;
currentContainer = this;
// If the service is already bound, return the existing instance
if (this.hasBound(service)) {
this.event$.next({
type: 'SERVICE_BIND',
boundeeID: service.ID,
bounderID: bounder?.ID // Return the bounder ID if it is defined, else assume its the container
})
return this.boundMap.get(service.ID) as InstanceType<T> // Casted as InstanceType<T> because service IDs and types are expected to match
}
// Detect circular dependency and throw error
if (this.bindStack.findIndex((serviceID) => serviceID === service.ID) !== -1) {
const circularServices = `${this.bindStack.join(' -> ')} -> ${service.ID}`
throw new Error(`Circular dependency detected.\nChain: ${circularServices}`)
}
// Push the service ID onto the bind stack to detect circular dependencies
this.bindStack.push(service.ID)
// Initialize the service and emit events
// NOTE: We need to cast the service to any as TypeScript thinks that the service is abstract
const instance: Service<any> = new (service as any)()
this.boundMap.set(service.ID, instance)
this.bindStack.pop()
this.event$.next({
type: 'SERVICE_INIT',
serviceID: service.ID,
})
this.event$.next({
type: 'SERVICE_BIND',
boundeeID: service.ID,
bounderID: bounder?.ID
})
// Restore the current container
currentContainer = oldCurrentContainer;
// We expect the return type to match the service definition
return instance as InstanceType<T>
}
/**
* Returns an iterator of the currently bound service IDs and their instances
*/
public getBoundServices(): IterableIterator<[string, Service<any>]> {
return this.boundMap.entries()
}
/**
* Returns the public container event stream
*/
public getEventStream(): Observable<ContainerEvent> {
return this.event$.asObservable()
}
}

View File

@@ -1,2 +0,0 @@
export * from "./container"
export * from "./service"

View File

@@ -1,65 +0,0 @@
import { Observable, Subject } from 'rxjs'
import { Container, currentContainer } from './container'
/**
* A Dioc service that can bound to a container and can bind dependency services.
*
* NOTE: Services cannot have a constructor that takes arguments.
*
* @template EventDef The type of events that can be emitted by the service. These will be accessible by event streams
*/
export abstract class Service<EventDef = {}> {
/**
* The internal event stream of the service
*/
private event$ = new Subject<EventDef>()
/** The container the service is bound to */
#container: Container
constructor() {
if (!currentContainer) {
throw new Error(
`Tried to initialize service with no container (ID: ${ (this.constructor as any).ID })`
)
}
this.#container = currentContainer
}
/**
* Binds a dependency service into this service.
* @param service The class reference of the service to bind
*/
protected bind<T extends typeof Service<any> & { ID: string }>(service: T): InstanceType<T> {
if (!currentContainer) {
throw new Error('No currentContainer defined.')
}
return currentContainer.bind(service, this.constructor as typeof Service<any> & { ID: string })
}
/**
* Returns the container the service is bound to
*/
protected getContainer(): Container {
return this.#container
}
/**
* Emits an event on the service's event stream
* @param event The event to emit
*/
protected emit(event: EventDef) {
this.event$.next(event)
}
/**
* Returns the event stream of the service
*/
public getEventStream(): Observable<EventDef> {
return this.event$.asObservable()
}
}

View File

@@ -1,33 +0,0 @@
import { Container, Service } from "./main";
/**
* A container that can be used for writing tests, contains additional methods
* for binding suitable for writing tests. (see `bindMock`).
*/
export class TestContainer extends Container {
/**
* Binds a mock service to the container.
*
* @param service
* @param mock
*/
public bindMock<
T extends typeof Service<any> & { ID: string },
U extends Partial<InstanceType<T>>
>(service: T, mock: U): U {
if (this.boundMap.has(service.ID)) {
throw new Error(`Service '${service.ID}' already bound to container. Did you already call bindMock on this ?`)
}
this.boundMap.set(service.ID, mock as any)
this.event$.next({
type: "SERVICE_BIND",
boundeeID: service.ID,
bounderID: undefined,
})
return mock
}
}

View File

@@ -1,34 +0,0 @@
import { Plugin, inject } from "vue"
import { Container } from "./container"
import { Service } from "./service"
const VUE_CONTAINER_KEY = Symbol()
// TODO: Some Vue version issue with plugin generics is breaking type checking
/**
* The Vue Dioc Plugin, this allows the composables to work and access the container
*
* NOTE: Make sure you add `vue` as dependency to be able to use this plugin (duh)
*/
export const diocPlugin: Plugin = {
install(app, { container }) {
app.provide(VUE_CONTAINER_KEY, container)
}
}
/**
* A composable that binds a service to a Vue Component
*
* @param service The class reference of the service to bind
*/
export function useService<
T extends typeof Service<any> & { ID: string }
>(service: T): InstanceType<T> {
const container = inject(VUE_CONTAINER_KEY) as Container | undefined | null
if (!container) {
throw new Error("Container not found, did you forget to install the dioc plugin?")
}
return container.bind(service)
}

View File

@@ -1,54 +0,0 @@
{
"name": "dioc",
"private": true,
"version": "0.1.0",
"type": "module",
"files": [
"dist",
"index.d.ts"
],
"main": "./dist/counter.umd.cjs",
"module": "./dist/counter.js",
"types": "./index.d.ts",
"exports": {
".": {
"types": "./dist/main.d.ts",
"require": "./dist/index.cjs",
"import": "./dist/index.js"
},
"./vue": {
"types": "./dist/vue.d.ts",
"require": "./dist/vue.cjs",
"import": "./dist/vue.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"require": "./dist/testing.cjs",
"import": "./dist/testing.js"
}
},
"scripts": {
"dev": "vite",
"build": "vite build && tsc --emitDeclarationOnly",
"prepare": "pnpm run build",
"test": "vitest run",
"do-test": "pnpm run test",
"test:watch": "vitest"
},
"devDependencies": {
"typescript": "^4.9.4",
"vite": "^4.0.4",
"vitest": "^0.29.3"
},
"dependencies": {
"rxjs": "^7.8.1"
},
"peerDependencies": {
"vue": "^3.2.25"
},
"peerDependenciesMeta": {
"vue": {
"optional": true
}
}
}

View File

@@ -1,262 +0,0 @@
import { it, expect, describe, vi } from "vitest"
import { Service } from "../lib/service"
import { Container, currentContainer, ContainerEvent } from "../lib/container"
class TestServiceA extends Service {
public static ID = "TestServiceA"
}
class TestServiceB extends Service {
public static ID = "TestServiceB"
// Marked public to allow for testing
public readonly serviceA = this.bind(TestServiceA)
}
describe("Container", () => {
describe("getBoundServiceWithID", () => {
it("returns the service instance if it is bound to the container", () => {
const container = new Container()
const service = container.bind(TestServiceA)
expect(container.getBoundServiceWithID(TestServiceA.ID)).toBe(service)
})
it("returns undefined if the service is not bound to the container", () => {
const container = new Container()
expect(container.getBoundServiceWithID(TestServiceA.ID)).toBeUndefined()
})
})
describe("bind", () => {
it("correctly binds the service to it", () => {
const container = new Container()
const service = container.bind(TestServiceA)
// @ts-expect-error getContainer is defined as a protected property, but we are leveraging it here to check
expect(service.getContainer()).toBe(container)
})
it("after bind, the current container is set back to its previous value", () => {
const originalValue = currentContainer
const container = new Container()
container.bind(TestServiceA)
expect(currentContainer).toBe(originalValue)
})
it("dependent services are registered in the same container", () => {
const container = new Container()
const serviceB = container.bind(TestServiceB)
// @ts-expect-error getContainer is defined as a protected property, but we are leveraging it here to check
expect(serviceB.serviceA.getContainer()).toBe(container)
})
it("binding an already initialized service returns the initialized instance (services are singletons)", () => {
const container = new Container()
const serviceA = container.bind(TestServiceA)
const serviceA2 = container.bind(TestServiceA)
expect(serviceA).toBe(serviceA2)
})
it("binding a service which is a dependency of another service returns the same instance created from the dependency resolution (services are singletons)", () => {
const container = new Container()
const serviceB = container.bind(TestServiceB)
const serviceA = container.bind(TestServiceA)
expect(serviceB.serviceA).toBe(serviceA)
})
it("binding an initialized service as a dependency returns the same instance", () => {
const container = new Container()
const serviceA = container.bind(TestServiceA)
const serviceB = container.bind(TestServiceB)
expect(serviceB.serviceA).toBe(serviceA)
})
it("container emits an init event when an uninitialized service is initialized via bind and event only called once", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_INIT" }],
void
>()
container.getEventStream().subscribe((ev) => {
if (ev.type === "SERVICE_INIT") {
serviceFunc(ev)
}
})
const instance = container.bind(TestServiceA)
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
type: "SERVICE_INIT",
serviceID: TestServiceA.ID,
})
})
it("the bind event emitted has an undefined bounderID when the service is bound directly to the container", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_BIND" }],
void
>()
container.getEventStream().subscribe((ev) => {
if (ev.type === "SERVICE_BIND") {
serviceFunc(ev)
}
})
container.bind(TestServiceA)
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceA.ID,
bounderID: undefined,
})
})
it("the bind event emitted has the correct bounderID when the service is bound to another service", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_BIND" }],
void
>()
container.getEventStream().subscribe((ev) => {
// We only care about the bind event of TestServiceA
if (ev.type === "SERVICE_BIND" && ev.boundeeID === TestServiceA.ID) {
serviceFunc(ev)
}
})
container.bind(TestServiceB)
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceA.ID,
bounderID: TestServiceB.ID,
})
})
})
describe("hasBound", () => {
it("returns true if the given service is bound to the container", () => {
const container = new Container()
container.bind(TestServiceA)
expect(container.hasBound(TestServiceA)).toEqual(true)
})
it("returns false if the given service is not bound to the container", () => {
const container = new Container()
expect(container.hasBound(TestServiceA)).toEqual(false)
})
it("returns true when the service is bound because it is a dependency of another service", () => {
const container = new Container()
container.bind(TestServiceB)
expect(container.hasBound(TestServiceA)).toEqual(true)
})
})
describe("getEventStream", () => {
it("returns an observable which emits events correctly when services are initialized", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_INIT" }],
void
>()
container.getEventStream().subscribe((ev) => {
if (ev.type === "SERVICE_INIT") {
serviceFunc(ev)
}
})
container.bind(TestServiceB)
expect(serviceFunc).toHaveBeenCalledTimes(2)
expect(serviceFunc).toHaveBeenNthCalledWith(1, <ContainerEvent>{
type: "SERVICE_INIT",
serviceID: TestServiceA.ID,
})
expect(serviceFunc).toHaveBeenNthCalledWith(2, <ContainerEvent>{
type: "SERVICE_INIT",
serviceID: TestServiceB.ID,
})
})
it("returns an observable which emits events correctly when services are bound", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_BIND" }],
void
>()
container.getEventStream().subscribe((ev) => {
if (ev.type === "SERVICE_BIND") {
serviceFunc(ev)
}
})
container.bind(TestServiceB)
expect(serviceFunc).toHaveBeenCalledTimes(2)
expect(serviceFunc).toHaveBeenNthCalledWith(1, <ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceA.ID,
bounderID: TestServiceB.ID,
})
expect(serviceFunc).toHaveBeenNthCalledWith(2, <ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceB.ID,
bounderID: undefined,
})
})
})
describe("getBoundServices", () => {
it("returns an iterator over all services bound to the container in the format [service id, service instance]", () => {
const container = new Container()
const instanceB = container.bind(TestServiceB)
const instanceA = instanceB.serviceA
expect(Array.from(container.getBoundServices())).toEqual([
[TestServiceA.ID, instanceA],
[TestServiceB.ID, instanceB],
])
})
it("returns an empty iterator if no services are bound", () => {
const container = new Container()
expect(Array.from(container.getBoundServices())).toEqual([])
})
})
})

View File

@@ -1,66 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { Service, Container } from "../lib/main"
class TestServiceA extends Service {
public static ID = "TestServiceA"
}
class TestServiceB extends Service<"test"> {
public static ID = "TestServiceB"
// Marked public to allow for testing
public readonly serviceA = this.bind(TestServiceA)
public emitTestEvent() {
this.emit("test")
}
}
describe("Service", () => {
describe("constructor", () => {
it("throws an error if the service is initialized without a container", () => {
expect(() => new TestServiceA()).toThrowError(
"Tried to initialize service with no container (ID: TestServiceA)"
)
})
})
describe("bind", () => {
it("correctly binds the dependency service using the container", () => {
const container = new Container()
const serviceA = container.bind(TestServiceA)
const serviceB = container.bind(TestServiceB)
expect(serviceB.serviceA).toBe(serviceA)
})
})
describe("getContainer", () => {
it("returns the container the service is bound to", () => {
const container = new Container()
const serviceA = container.bind(TestServiceA)
// @ts-expect-error getContainer is a protected member, we are just using it to help with testing
expect(serviceA.getContainer()).toBe(container)
})
})
describe("getEventStream", () => {
it("returns the valid event stream of the service", () => {
const container = new Container()
const serviceB = container.bind(TestServiceB)
const serviceFunc = vi.fn()
serviceB.getEventStream().subscribe(serviceFunc)
serviceB.emitTestEvent()
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith("test")
})
})
})

View File

@@ -1,92 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { TestContainer } from "../lib/testing"
import { Service } from "../lib/service"
import { ContainerEvent } from "../lib/container"
class TestServiceA extends Service {
public static ID = "TestServiceA"
public test() {
return "real"
}
}
class TestServiceB extends Service {
public static ID = "TestServiceB"
// declared public to help with testing
public readonly serviceA = this.bind(TestServiceA)
public test() {
return this.serviceA.test()
}
}
describe("TestContainer", () => {
describe("bindMock", () => {
it("returns the fake service defined", () => {
const container = new TestContainer()
const fakeService = {
test: () => "fake",
}
const result = container.bindMock(TestServiceA, fakeService)
expect(result).toBe(fakeService)
})
it("new services bound to the container get the mock service", () => {
const container = new TestContainer()
const fakeServiceA = {
test: () => "fake",
}
container.bindMock(TestServiceA, fakeServiceA)
const serviceB = container.bind(TestServiceB)
expect(serviceB.serviceA).toBe(fakeServiceA)
})
it("container emits SERVICE_BIND event", () => {
const container = new TestContainer()
const fakeServiceA = {
test: () => "fake",
}
const serviceFunc = vi.fn<[ContainerEvent, void]>()
container.getEventStream().subscribe((ev) => {
serviceFunc(ev)
})
container.bindMock(TestServiceA, fakeServiceA)
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceA.ID,
bounderID: undefined,
})
})
it("throws if service already bound", () => {
const container = new TestContainer()
const fakeServiceA = {
test: () => "fake",
}
container.bindMock(TestServiceA, fakeServiceA)
expect(() => {
container.bindMock(TestServiceA, fakeServiceA)
}).toThrowError(
"Service 'TestServiceA' already bound to container. Did you already call bindMock on this ?"
)
})
})
})

View File

@@ -1,2 +0,0 @@
export { default } from "./dist/testing.d.ts"
export * from "./dist/testing.d.ts"

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM"],
"moduleResolution": "Node",
"strict": true,
"declaration": true,
"sourceMap": true,
"outDir": "dist",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true
},
"include": ["lib"]
}

View File

@@ -1,16 +0,0 @@
import { defineConfig } from 'vite'
export default defineConfig({
build: {
lib: {
entry: {
index: './lib/main.ts',
vue: './lib/vue.ts',
testing: './lib/testing.ts',
},
},
rollupOptions: {
external: ['vue'],
}
},
})

View File

@@ -1,7 +0,0 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
}
})

View File

@@ -1,2 +0,0 @@
export { default } from "./dist/vue.d.ts"
export * from "./dist/vue.d.ts"

View File

@@ -1,4 +1,4 @@
FROM node:18.8.0 AS builder
FROM node:20.12.2 AS builder
WORKDIR /usr/src/app

View File

@@ -3,9 +3,7 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"assets": [
"**/*.hbs"
],
"assets": [{ "include": "mailer/templates/**/*", "outDir": "dist" }],
"watchAssets": true
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "hoppscotch-backend",
"version": "2023.8.4-1",
"version": "2024.7.0",
"description": "",
"author": "",
"private": true,
@@ -24,79 +24,87 @@
"do-test": "pnpm run test"
},
"dependencies": {
"@apollo/server": "^4.9.4",
"@nestjs-modules/mailer": "^1.9.1",
"@nestjs/apollo": "^12.0.9",
"@nestjs/common": "^10.2.6",
"@nestjs/core": "^10.2.6",
"@nestjs/graphql": "^12.0.9",
"@nestjs/jwt": "^10.1.1",
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^10.2.6",
"@nestjs/throttler": "^5.0.0",
"@prisma/client": "^4.16.2",
"argon2": "^0.30.3",
"bcrypt": "^5.1.0",
"cookie": "^0.5.0",
"cookie-parser": "^1.4.6",
"express": "^4.17.1",
"express-session": "^1.17.3",
"fp-ts": "^2.13.1",
"graphql": "^16.8.1",
"graphql-query-complexity": "^0.12.0",
"graphql-redis-subscriptions": "^2.6.0",
"graphql-subscriptions": "^2.0.0",
"handlebars": "^4.7.7",
"io-ts": "^2.2.16",
"luxon": "^3.2.1",
"nodemailer": "^6.9.1",
"passport": "^0.6.0",
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"passport-microsoft": "^1.0.0",
"prisma": "^4.16.2",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.6.0"
"@apollo/server": "4.9.5",
"@nestjs-modules/mailer": "1.9.1",
"@nestjs/apollo": "12.0.9",
"@nestjs/common": "10.2.7",
"@nestjs/config": "3.1.1",
"@nestjs/core": "10.2.7",
"@nestjs/graphql": "12.0.9",
"@nestjs/jwt": "10.1.1",
"@nestjs/passport": "10.0.2",
"@nestjs/platform-express": "10.2.7",
"@nestjs/schedule": "4.0.1",
"@nestjs/swagger": "7.4.0",
"@nestjs/terminus": "10.2.3",
"@nestjs/throttler": "5.0.1",
"@prisma/client": "5.8.1",
"argon2": "0.30.3",
"bcrypt": "5.1.0",
"class-transformer": "0.5.1",
"class-validator": "0.14.1",
"cookie": "0.5.0",
"cookie-parser": "1.4.6",
"cron": "3.1.6",
"express": "4.18.2",
"express-session": "1.17.3",
"fp-ts": "2.13.1",
"graphql": "16.8.1",
"graphql-query-complexity": "0.12.0",
"graphql-redis-subscriptions": "2.6.0",
"graphql-subscriptions": "2.0.0",
"handlebars": "4.7.7",
"io-ts": "2.2.16",
"luxon": "3.2.1",
"nodemailer": "6.9.1",
"passport": "0.6.0",
"passport-github2": "0.1.12",
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.1",
"passport-local": "1.0.0",
"passport-microsoft": "1.0.0",
"posthog-node": "3.6.3",
"prisma": "5.8.1",
"reflect-metadata": "0.1.13",
"rimraf": "3.0.2",
"rxjs": "7.6.0"
},
"devDependencies": {
"@nestjs/cli": "^10.1.18",
"@nestjs/schematics": "^10.0.2",
"@nestjs/testing": "^10.2.6",
"@relmify/jest-fp-ts": "^2.0.2",
"@types/argon2": "^0.15.0",
"@types/bcrypt": "^5.0.0",
"@types/cookie": "^0.5.1",
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.14",
"@types/jest": "^29.4.0",
"@types/luxon": "^3.2.0",
"@types/node": "^18.11.10",
"@types/nodemailer": "^6.4.7",
"@types/passport-github2": "^1.2.5",
"@types/passport-google-oauth20": "^2.0.11",
"@types/passport-jwt": "^3.0.8",
"@types/passport-microsoft": "^0.0.0",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"cross-env": "^7.0.3",
"eslint": "^8.29.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"jest": "^29.4.1",
"jest-mock-extended": "^3.0.1",
"@nestjs/cli": "10.2.1",
"@nestjs/schematics": "10.0.3",
"@nestjs/testing": "10.2.7",
"@relmify/jest-fp-ts": "2.0.2",
"@types/argon2": "0.15.0",
"@types/bcrypt": "5.0.0",
"@types/cookie": "0.5.1",
"@types/cookie-parser": "1.4.3",
"@types/express": "4.17.14",
"@types/jest": "29.4.0",
"@types/luxon": "3.2.0",
"@types/node": "18.11.10",
"@types/nodemailer": "6.4.7",
"@types/passport-github2": "1.2.5",
"@types/passport-google-oauth20": "2.0.11",
"@types/passport-jwt": "3.0.8",
"@types/passport-microsoft": "0.0.0",
"@types/supertest": "2.0.12",
"@typescript-eslint/eslint-plugin": "5.45.0",
"@typescript-eslint/parser": "5.45.0",
"cross-env": "7.0.3",
"eslint": "8.29.0",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-prettier": "4.2.1",
"jest": "29.4.1",
"jest-mock-extended": "3.0.1",
"jwt": "link:@types/nestjs/jwt",
"prettier": "^2.8.4",
"source-map-support": "^0.5.21",
"supertest": "^6.3.2",
"prettier": "2.8.4",
"source-map-support": "0.5.21",
"supertest": "6.3.2",
"ts-jest": "29.0.5",
"ts-loader": "^9.4.2",
"ts-node": "^10.9.1",
"ts-loader": "9.4.2",
"ts-node": "10.9.1",
"tsconfig-paths": "4.1.1",
"typescript": "^4.9.3"
"typescript": "4.9.3"
},
"jest": {
"moduleFileExtensions": [

View File

@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "InfraConfig" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"value" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedOn" TIMESTAMP(3) NOT NULL,
CONSTRAINT "InfraConfig_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "InfraConfig_name_key" ON "InfraConfig"("name");

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "TeamCollection" ADD COLUMN "data" JSONB;
-- AlterTable
ALTER TABLE "UserCollection" ADD COLUMN "data" JSONB;

View File

@@ -0,0 +1,22 @@
-- This is a custom migration file which is not generated by Prisma.
-- The aim of this migration is to add text search indices to the TeamCollection and TeamRequest tables.
-- Create Extension
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Create GIN Trigram Index for Team Collection title
CREATE INDEX
"TeamCollection_title_trgm_idx"
ON
"TeamCollection"
USING
GIN (title gin_trgm_ops);
-- Create GIN Trigram Index for Team Collection title
CREATE INDEX
"TeamRequest_title_trgm_idx"
ON
"TeamRequest"
USING
GIN (title gin_trgm_ops);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "lastLoggedOn" TIMESTAMP(3);

View File

@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "PersonalAccessToken" (
"id" TEXT NOT NULL,
"userUid" TEXT NOT NULL,
"label" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresOn" TIMESTAMP(3),
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedOn" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PersonalAccessToken_token_key" ON "PersonalAccessToken"("token");
-- AddForeignKey
ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "lastActiveOn" TIMESTAMP(3);

View File

@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "InfraToken" (
"id" TEXT NOT NULL,
"creatorUid" TEXT NOT NULL,
"label" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresOn" TIMESTAMP(3),
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "InfraToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "InfraToken_token_key" ON "InfraToken"("token");

View File

@@ -43,6 +43,7 @@ model TeamInvitation {
model TeamCollection {
id String @id @default(cuid())
parentID String?
data Json?
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
children TeamCollection[] @relation("TeamCollectionChildParent")
requests TeamRequest[]
@@ -74,7 +75,8 @@ model Shortcode {
creatorUid String?
User User? @relation(fields: [creatorUid], references: [uid])
createdOn DateTime @default(now())
updatedOn DateTime @updatedAt @default(now())
updatedOn DateTime @default(now()) @updatedAt
@@unique(fields: [id, creatorUid], name: "creator_uid_shortcode_unique")
}
@@ -87,24 +89,27 @@ model TeamEnvironment {
}
model User {
uid String @id @default(cuid())
displayName String?
email String? @unique
photoURL String?
isAdmin Boolean @default(false)
refreshToken String?
providerAccounts Account[]
VerificationToken VerificationToken[]
settings UserSettings?
UserHistory UserHistory[]
UserEnvironments UserEnvironment[]
userCollections UserCollection[]
userRequests UserRequest[]
currentRESTSession Json?
currentGQLSession Json?
createdOn DateTime @default(now()) @db.Timestamp(3)
invitedUsers InvitedUsers[]
shortcodes Shortcode[]
uid String @id @default(cuid())
displayName String?
email String? @unique
photoURL String?
isAdmin Boolean @default(false)
refreshToken String?
providerAccounts Account[]
VerificationToken VerificationToken[]
settings UserSettings?
UserHistory UserHistory[]
UserEnvironments UserEnvironment[]
userCollections UserCollection[]
userRequests UserRequest[]
currentRESTSession Json?
currentGQLSession Json?
lastLoggedOn DateTime? @db.Timestamp(3)
lastActiveOn DateTime? @db.Timestamp(3)
createdOn DateTime @default(now()) @db.Timestamp(3)
invitedUsers InvitedUsers[]
shortcodes Shortcode[]
personalAccessTokens PersonalAccessToken[]
}
model Account {
@@ -195,6 +200,7 @@ model UserCollection {
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
title String
data Json?
orderIndex Int
type ReqType
createdOn DateTime @default(now()) @db.Timestamp(3)
@@ -206,3 +212,33 @@ enum TeamMemberRole {
VIEWER
EDITOR
}
model InfraConfig {
id String @id @default(cuid())
name String @unique
value String?
active Boolean @default(true) // Use case: Let's say, Admin wants to disable Google SSO, but doesn't want to delete the config
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}
model PersonalAccessToken {
id String @id @default(cuid())
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
label String
token String @unique @default(uuid())
expiresOn DateTime? @db.Timestamp(3)
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}
model InfraToken {
id String @id @default(cuid())
creatorUid String
label String
token String @unique @default(uuid())
expiresOn DateTime? @db.Timestamp(3)
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @default(now()) @db.Timestamp(3)
}

View File

@@ -0,0 +1,107 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpStatus,
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AccessTokenService } from './access-token.service';
import { CreateAccessTokenDto } from './dto/create-access-token.dto';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import * as E from 'fp-ts/Either';
import { throwHTTPErr } from 'src/utils';
import { GqlUser } from 'src/decorators/gql-user.decorator';
import { AuthUser } from 'src/types/AuthUser';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import { PATAuthGuard } from 'src/guards/rest-pat-auth.guard';
import { AccessTokenInterceptor } from 'src/interceptors/access-token.interceptor';
import { TeamEnvironmentsService } from 'src/team-environments/team-environments.service';
import { TeamCollectionService } from 'src/team-collection/team-collection.service';
import { ACCESS_TOKENS_INVALID_DATA_ID } from 'src/errors';
import { createCLIErrorResponse } from './helper';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'access-tokens', version: '1' })
export class AccessTokenController {
constructor(
private readonly accessTokenService: AccessTokenService,
private readonly teamCollectionService: TeamCollectionService,
private readonly teamEnvironmentsService: TeamEnvironmentsService,
) {}
@Post('create')
@UseGuards(JwtAuthGuard)
async createPAT(
@GqlUser() user: AuthUser,
@Body() createAccessTokenDto: CreateAccessTokenDto,
) {
const result = await this.accessTokenService.createPAT(
createAccessTokenDto,
user,
);
if (E.isLeft(result)) throwHTTPErr(result.left);
return result.right;
}
@Delete('revoke')
@UseGuards(JwtAuthGuard)
async deletePAT(@Query('id') id: string) {
const result = await this.accessTokenService.deletePAT(id);
if (E.isLeft(result)) throwHTTPErr(result.left);
return result.right;
}
@Get('list')
@UseGuards(JwtAuthGuard)
async listAllUserPAT(
@GqlUser() user: AuthUser,
@Query('offset', ParseIntPipe) offset: number,
@Query('limit', ParseIntPipe) limit: number,
) {
return await this.accessTokenService.listAllUserPAT(
user.uid,
offset,
limit,
);
}
@Get('collection/:id')
@UseGuards(PATAuthGuard)
@UseInterceptors(AccessTokenInterceptor)
async fetchCollection(@GqlUser() user: AuthUser, @Param('id') id: string) {
const res = await this.teamCollectionService.getCollectionForCLI(
id,
user.uid,
);
if (E.isLeft(res))
throw new BadRequestException(
createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID),
);
return res.right;
}
@Get('environment/:id')
@UseGuards(PATAuthGuard)
@UseInterceptors(AccessTokenInterceptor)
async fetchEnvironment(@GqlUser() user: AuthUser, @Param('id') id: string) {
const res = await this.teamEnvironmentsService.getTeamEnvironmentForCLI(
id,
user.uid,
);
if (E.isLeft(res))
throw new BadRequestException(
createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID),
);
return res.right;
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { AccessTokenController } from './access-token.controller';
import { PrismaModule } from 'src/prisma/prisma.module';
import { AccessTokenService } from './access-token.service';
import { TeamCollectionModule } from 'src/team-collection/team-collection.module';
import { TeamEnvironmentsModule } from 'src/team-environments/team-environments.module';
import { TeamModule } from 'src/team/team.module';
@Module({
imports: [
PrismaModule,
TeamCollectionModule,
TeamEnvironmentsModule,
TeamModule,
],
controllers: [AccessTokenController],
providers: [AccessTokenService],
exports: [AccessTokenService],
})
export class AccessTokenModule {}

View File

@@ -0,0 +1,196 @@
import { AccessTokenService } from './access-token.service';
import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaService } from 'src/prisma/prisma.service';
import {
ACCESS_TOKEN_EXPIRY_INVALID,
ACCESS_TOKEN_LABEL_SHORT,
ACCESS_TOKEN_NOT_FOUND,
} from 'src/errors';
import { AuthUser } from 'src/types/AuthUser';
import { PersonalAccessToken } from '@prisma/client';
import { AccessToken } from 'src/types/AccessToken';
import { HttpStatus } from '@nestjs/common';
const mockPrisma = mockDeep<PrismaService>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const accessTokenService = new AccessTokenService(mockPrisma);
const currentTime = new Date();
const user: AuthUser = {
uid: '123344',
email: 'dwight@dundermifflin.com',
displayName: 'Dwight Schrute',
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false,
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
createdOn: currentTime,
currentGQLSession: {},
currentRESTSession: {},
lastLoggedOn: currentTime,
lastActiveOn: currentTime,
};
const PATCreatedOn = new Date();
const expiryInDays = 7;
const PATExpiresOn = new Date(
PATCreatedOn.getTime() + expiryInDays * 24 * 60 * 60 * 1000,
);
const userAccessToken: PersonalAccessToken = {
id: 'skfvhj8uvdfivb',
userUid: user.uid,
label: 'test',
token: '0140e328-b187-4823-ae4b-ed4bec832ac2',
expiresOn: PATExpiresOn,
createdOn: PATCreatedOn,
updatedOn: new Date(),
};
const userAccessTokenCasted: AccessToken = {
id: userAccessToken.id,
label: userAccessToken.label,
createdOn: userAccessToken.createdOn,
lastUsedOn: userAccessToken.updatedOn,
expiresOn: userAccessToken.expiresOn,
};
beforeEach(() => {
mockReset(mockPrisma);
});
describe('AccessTokenService', () => {
describe('createPAT', () => {
test('should throw ACCESS_TOKEN_LABEL_SHORT if label is too short', async () => {
const result = await accessTokenService.createPAT(
{
label: 'a',
expiryInDays: 7,
},
user,
);
expect(result).toEqualLeft({
message: ACCESS_TOKEN_LABEL_SHORT,
statusCode: HttpStatus.BAD_REQUEST,
});
});
test('should throw ACCESS_TOKEN_EXPIRY_INVALID if expiry date is invalid', async () => {
const result = await accessTokenService.createPAT(
{
label: 'test',
expiryInDays: 9,
},
user,
);
expect(result).toEqualLeft({
message: ACCESS_TOKEN_EXPIRY_INVALID,
statusCode: HttpStatus.BAD_REQUEST,
});
});
test('should successfully create a new Access Token', async () => {
mockPrisma.personalAccessToken.create.mockResolvedValueOnce(
userAccessToken,
);
const result = await accessTokenService.createPAT(
{
label: userAccessToken.label,
expiryInDays,
},
user,
);
expect(result).toEqualRight({
token: `pat-${userAccessToken.token}`,
info: userAccessTokenCasted,
});
});
});
describe('deletePAT', () => {
test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => {
mockPrisma.personalAccessToken.delete.mockRejectedValueOnce(
'RecordNotFound',
);
const result = await accessTokenService.deletePAT(userAccessToken.id);
expect(result).toEqualLeft({
message: ACCESS_TOKEN_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,
});
});
test('should successfully delete a new Access Token', async () => {
mockPrisma.personalAccessToken.delete.mockResolvedValueOnce(
userAccessToken,
);
const result = await accessTokenService.deletePAT(userAccessToken.id);
expect(result).toEqualRight(true);
});
});
describe('listAllUserPAT', () => {
test('should successfully return a list of user Access Tokens', async () => {
mockPrisma.personalAccessToken.findMany.mockResolvedValueOnce([
userAccessToken,
]);
const result = await accessTokenService.listAllUserPAT(user.uid, 0, 10);
expect(result).toEqual([userAccessTokenCasted]);
});
});
describe('getUserPAT', () => {
test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => {
mockPrisma.personalAccessToken.findUniqueOrThrow.mockRejectedValueOnce(
'NotFoundError',
);
const result = await accessTokenService.getUserPAT(userAccessToken.token);
expect(result).toEqualLeft(ACCESS_TOKEN_NOT_FOUND);
});
test('should successfully return a user Access Tokens', async () => {
mockPrisma.personalAccessToken.findUniqueOrThrow.mockResolvedValueOnce({
...userAccessToken,
user,
} as any);
const result = await accessTokenService.getUserPAT(
`pat-${userAccessToken.token}`,
);
expect(result).toEqualRight({
user,
...userAccessToken,
} as any);
});
});
describe('updateLastUsedforPAT', () => {
test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => {
mockPrisma.personalAccessToken.update.mockRejectedValueOnce(
'RecordNotFound',
);
const result = await accessTokenService.updateLastUsedForPAT(
userAccessToken.token,
);
expect(result).toEqualLeft(ACCESS_TOKEN_NOT_FOUND);
});
test('should successfully update lastUsedOn for a user Access Tokens', async () => {
mockPrisma.personalAccessToken.update.mockResolvedValueOnce(
userAccessToken,
);
const result = await accessTokenService.updateLastUsedForPAT(
`pat-${userAccessToken.token}`,
);
expect(result).toEqualRight(userAccessTokenCasted);
});
});
});

View File

@@ -0,0 +1,190 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { CreateAccessTokenDto } from './dto/create-access-token.dto';
import { AuthUser } from 'src/types/AuthUser';
import { calculateExpirationDate, isValidLength } from 'src/utils';
import * as E from 'fp-ts/Either';
import {
ACCESS_TOKEN_EXPIRY_INVALID,
ACCESS_TOKEN_LABEL_SHORT,
ACCESS_TOKEN_NOT_FOUND,
} from 'src/errors';
import { CreateAccessTokenResponse } from './helper';
import { PersonalAccessToken } from '@prisma/client';
import { AccessToken } from 'src/types/AccessToken';
@Injectable()
export class AccessTokenService {
constructor(private readonly prisma: PrismaService) {}
TITLE_LENGTH = 3;
VALID_TOKEN_DURATIONS = [7, 30, 60, 90];
TOKEN_PREFIX = 'pat-';
/**
* Validate the expiration date of the token
*
* @param expiresOn Number of days the token is valid for
* @returns Boolean indicating if the expiration date is valid
*/
private validateExpirationDate(expiresOn: null | number) {
if (expiresOn === null || this.VALID_TOKEN_DURATIONS.includes(expiresOn))
return true;
return false;
}
/**
* Typecast a database PersonalAccessToken to a AccessToken model
* @param token database PersonalAccessToken
* @returns AccessToken model
*/
private cast(token: PersonalAccessToken): AccessToken {
return <AccessToken>{
id: token.id,
label: token.label,
createdOn: token.createdOn,
expiresOn: token.expiresOn,
lastUsedOn: token.updatedOn,
};
}
/**
* Extract UUID from the token
*
* @param token Personal Access Token
* @returns UUID of the token
*/
private extractUUID(token): string | null {
if (!token.startsWith(this.TOKEN_PREFIX)) return null;
return token.slice(this.TOKEN_PREFIX.length);
}
/**
* Create a Personal Access Token
*
* @param createAccessTokenDto DTO for creating a Personal Access Token
* @param user AuthUser object
* @returns Either of the created token or error message
*/
async createPAT(createAccessTokenDto: CreateAccessTokenDto, user: AuthUser) {
const isTitleValid = isValidLength(
createAccessTokenDto.label,
this.TITLE_LENGTH,
);
if (!isTitleValid)
return E.left({
message: ACCESS_TOKEN_LABEL_SHORT,
statusCode: HttpStatus.BAD_REQUEST,
});
if (!this.validateExpirationDate(createAccessTokenDto.expiryInDays))
return E.left({
message: ACCESS_TOKEN_EXPIRY_INVALID,
statusCode: HttpStatus.BAD_REQUEST,
});
const createdPAT = await this.prisma.personalAccessToken.create({
data: {
userUid: user.uid,
label: createAccessTokenDto.label,
expiresOn: calculateExpirationDate(createAccessTokenDto.expiryInDays),
},
});
const res: CreateAccessTokenResponse = {
token: `${this.TOKEN_PREFIX}${createdPAT.token}`,
info: this.cast(createdPAT),
};
return E.right(res);
}
/**
* Delete a Personal Access Token
*
* @param accessTokenID ID of the Personal Access Token
* @returns Either of true or error message
*/
async deletePAT(accessTokenID: string) {
try {
await this.prisma.personalAccessToken.delete({
where: { id: accessTokenID },
});
return E.right(true);
} catch {
return E.left({
message: ACCESS_TOKEN_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,
});
}
}
/**
* List all Personal Access Tokens of a user
*
* @param userUid UID of the user
* @param offset Offset for pagination
* @param limit Limit for pagination
* @returns Either of the list of Personal Access Tokens or error message
*/
async listAllUserPAT(userUid: string, offset: number, limit: number) {
const userPATs = await this.prisma.personalAccessToken.findMany({
where: {
userUid: userUid,
},
skip: offset,
take: limit,
orderBy: {
createdOn: 'desc',
},
});
const userAccessTokenList = userPATs.map((pat) => this.cast(pat));
return userAccessTokenList;
}
/**
* Get a Personal Access Token
*
* @param accessToken Personal Access Token
* @returns Either of the Personal Access Token or error message
*/
async getUserPAT(accessToken: string) {
const extractedToken = this.extractUUID(accessToken);
if (!extractedToken) return E.left(ACCESS_TOKEN_NOT_FOUND);
try {
const userPAT = await this.prisma.personalAccessToken.findUniqueOrThrow({
where: { token: extractedToken },
include: { user: true },
});
return E.right(userPAT);
} catch {
return E.left(ACCESS_TOKEN_NOT_FOUND);
}
}
/**
* Update the last used date of a Personal Access Token
*
* @param token Personal Access Token
* @returns Either of the updated Personal Access Token or error message
*/
async updateLastUsedForPAT(token: string) {
const extractedToken = this.extractUUID(token);
if (!extractedToken) return E.left(ACCESS_TOKEN_NOT_FOUND);
try {
const updatedAccessToken = await this.prisma.personalAccessToken.update({
where: { token: extractedToken },
data: {
updatedOn: new Date(),
},
});
return E.right(this.cast(updatedAccessToken));
} catch {
return E.left(ACCESS_TOKEN_NOT_FOUND);
}
}
}

View File

@@ -0,0 +1,5 @@
// Inputs to create a new PAT
export class CreateAccessTokenDto {
label: string;
expiryInDays: number | null;
}

View File

@@ -0,0 +1,17 @@
import { AccessToken } from 'src/types/AccessToken';
// Response type of PAT creation method
export type CreateAccessTokenResponse = {
token: string;
info: AccessToken;
};
// Response type of any error in PAT module
export type CLIErrorResponse = {
reason: string;
};
// Return a CLIErrorResponse object
export function createCLIErrorResponse(reason: string): CLIErrorResponse {
return { reason };
}

View File

@@ -4,7 +4,6 @@ import { AdminService } from './admin.service';
import { PrismaModule } from '../prisma/prisma.module';
import { PubSubModule } from '../pubsub/pubsub.module';
import { UserModule } from '../user/user.module';
import { MailerModule } from '../mailer/mailer.module';
import { TeamModule } from '../team/team.module';
import { TeamInvitationModule } from '../team-invitation/team-invitation.module';
import { TeamEnvironmentsModule } from '../team-environments/team-environments.module';
@@ -12,19 +11,20 @@ import { TeamCollectionModule } from '../team-collection/team-collection.module'
import { TeamRequestModule } from '../team-request/team-request.module';
import { InfraResolver } from './infra.resolver';
import { ShortcodeModule } from 'src/shortcode/shortcode.module';
import { InfraConfigModule } from 'src/infra-config/infra-config.module';
@Module({
imports: [
PrismaModule,
PubSubModule,
UserModule,
MailerModule,
TeamModule,
TeamInvitationModule,
TeamEnvironmentsModule,
TeamCollectionModule,
TeamRequestModule,
ShortcodeModule,
InfraConfigModule,
],
providers: [InfraResolver, AdminResolver, AdminService],
exports: [AdminService],

View File

@@ -27,9 +27,7 @@ import {
} from './input-types.args';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { SkipThrottle } from '@nestjs/throttler';
import { User } from 'src/user/user.model';
import { PaginationArgs } from 'src/types/input-types.args';
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
import { UserDeletionResult } from 'src/user/user.model';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => Admin)
@@ -49,203 +47,6 @@ export class AdminResolver {
return admin;
}
@ResolveField(() => [User], {
description: 'Returns a list of all admin users in infra',
deprecationReason: 'Use `infra` query instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async admins() {
const admins = await this.adminService.fetchAdmins();
return admins;
}
@ResolveField(() => User, {
description: 'Returns a user info by UID',
deprecationReason: 'Use `infra` query instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async userInfo(
@Args({
name: 'userUid',
type: () => ID,
description: 'The user UID',
})
userUid: string,
): Promise<AuthUser> {
const user = await this.adminService.fetchUserInfo(userUid);
if (E.isLeft(user)) throwErr(user.left);
return user.right;
}
@ResolveField(() => [User], {
description: 'Returns a list of all the users in infra',
deprecationReason: 'Use `infra` query instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async allUsers(
@Parent() admin: Admin,
@Args() args: PaginationArgs,
): Promise<AuthUser[]> {
const users = await this.adminService.fetchUsers(args.cursor, args.take);
return users;
}
@ResolveField(() => [InvitedUser], {
description: 'Returns a list of all the invited users',
deprecationReason: 'Use `infra` query instead',
})
async invitedUsers(@Parent() admin: Admin): Promise<InvitedUser[]> {
const users = await this.adminService.fetchInvitedUsers();
return users;
}
@ResolveField(() => [Team], {
description: 'Returns a list of all the teams in the infra',
deprecationReason: 'Use `infra` query instead',
})
async allTeams(
@Parent() admin: Admin,
@Args() args: PaginationArgs,
): Promise<Team[]> {
const teams = await this.adminService.fetchAllTeams(args.cursor, args.take);
return teams;
}
@ResolveField(() => Team, {
description: 'Returns a team info by ID when requested by Admin',
deprecationReason: 'Use `infra` query instead',
})
async teamInfo(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which info to fetch',
})
teamID: string,
): Promise<Team> {
const team = await this.adminService.getTeamInfo(teamID);
if (E.isLeft(team)) throwErr(team.left);
return team.right;
}
@ResolveField(() => Number, {
description: 'Return count of all the members in a team',
deprecationReason: 'Use `infra` query instead',
})
async membersCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
nullable: false,
})
teamID: string,
): Promise<number> {
const teamMembersCount = await this.adminService.membersCountInTeam(teamID);
return teamMembersCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored collections in a team',
deprecationReason: 'Use `infra` query instead',
})
async collectionCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const teamCollCount = await this.adminService.collectionCountInTeam(teamID);
return teamCollCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored requests in a team',
deprecationReason: 'Use `infra` query instead',
})
async requestCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const teamReqCount = await this.adminService.requestCountInTeam(teamID);
return teamReqCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored environments in a team',
deprecationReason: 'Use `infra` query instead',
})
async environmentCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const envsCount = await this.adminService.environmentCountInTeam(teamID);
return envsCount;
}
@ResolveField(() => [TeamInvitation], {
description: 'Return all the pending invitations in a team',
deprecationReason: 'Use `infra` query instead',
})
async pendingInvitationCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
) {
const invitations = await this.adminService.pendingInvitationCountInTeam(
teamID,
);
return invitations;
}
@ResolveField(() => Number, {
description: 'Return total number of Users in organization',
deprecationReason: 'Use `infra` query instead',
})
async usersCount() {
return this.adminService.getUsersCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Teams in organization',
deprecationReason: 'Use `infra` query instead',
})
async teamsCount() {
return this.adminService.getTeamsCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Team Collections in organization',
deprecationReason: 'Use `infra` query instead',
})
async teamCollectionsCount() {
return this.adminService.getTeamCollectionsCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Team Requests in organization',
deprecationReason: 'Use `infra` query instead',
})
async teamRequestsCount() {
return this.adminService.getTeamRequestsCount();
}
/* Mutations */
@Mutation(() => InvitedUser, {
@@ -269,8 +70,26 @@ export class AdminResolver {
return invitedUser.right;
}
@Mutation(() => Boolean, {
description: 'Revoke a user invites by invitee emails',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async revokeUserInvitationsByAdmin(
@Args({
name: 'inviteeEmails',
description: 'Invitee Emails',
type: () => [String],
})
inviteeEmails: string[],
): Promise<boolean> {
const invite = await this.adminService.revokeUserInvitations(inviteeEmails);
if (E.isLeft(invite)) throwErr(invite.left);
return invite.right;
}
@Mutation(() => Boolean, {
description: 'Delete an user account from infra',
deprecationReason: 'Use removeUsersByAdmin instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async removeUserByAdmin(
@@ -281,12 +100,33 @@ export class AdminResolver {
})
userUID: string,
): Promise<boolean> {
const invitedUser = await this.adminService.removeUserAccount(userUID);
if (E.isLeft(invitedUser)) throwErr(invitedUser.left);
return invitedUser.right;
const removedUser = await this.adminService.removeUserAccount(userUID);
if (E.isLeft(removedUser)) throwErr(removedUser.left);
return removedUser.right;
}
@Mutation(() => [UserDeletionResult], {
description: 'Delete user accounts from infra',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async removeUsersByAdmin(
@Args({
name: 'userUIDs',
description: 'users UID',
type: () => [ID],
})
userUIDs: string[],
): Promise<UserDeletionResult[]> {
const deletionResults = await this.adminService.removeUserAccounts(
userUIDs,
);
if (E.isLeft(deletionResults)) throwErr(deletionResults.left);
return deletionResults.right;
}
@Mutation(() => Boolean, {
description: 'Make user an admin',
deprecationReason: 'Use makeUsersAdmin instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async makeUserAdmin(
@@ -302,8 +142,51 @@ export class AdminResolver {
return admin.right;
}
@Mutation(() => Boolean, {
description: 'Make users an admin',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async makeUsersAdmin(
@Args({
name: 'userUIDs',
description: 'users UID',
type: () => [ID],
})
userUIDs: string[],
): Promise<boolean> {
const isUpdated = await this.adminService.makeUsersAdmin(userUIDs);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return isUpdated.right;
}
@Mutation(() => Boolean, {
description: 'Update user display name',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async updateUserDisplayNameByAdmin(
@Args({
name: 'userUID',
description: 'users UID',
type: () => ID,
})
userUID: string,
@Args({
name: 'displayName',
description: 'users display name',
})
displayName: string,
): Promise<boolean> {
const isUpdated = await this.adminService.updateUserDisplayName(
userUID,
displayName,
);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return isUpdated.right;
}
@Mutation(() => Boolean, {
description: 'Remove user as admin',
deprecationReason: 'Use demoteUsersByAdmin instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async removeUserAsAdmin(
@@ -319,6 +202,23 @@ export class AdminResolver {
return admin.right;
}
@Mutation(() => Boolean, {
description: 'Remove users as admin',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async demoteUsersByAdmin(
@Args({
name: 'userUIDs',
description: 'users UID',
type: () => [ID],
})
userUIDs: string[],
): Promise<boolean> {
const isUpdated = await this.adminService.demoteUsersByAdmin(userUIDs);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return isUpdated.right;
}
@Mutation(() => Team, {
description:
'Create a new team by providing the user uid to nominate as Team owner',

View File

@@ -1,7 +1,7 @@
import { AdminService } from './admin.service';
import { PubSubService } from '../pubsub/pubsub.service';
import { mockDeep } from 'jest-mock-extended';
import { InvitedUsers } from '@prisma/client';
import { InvitedUsers, User as DbUser } from '@prisma/client';
import { UserService } from '../user/user.service';
import { TeamService } from '../team/team.service';
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
@@ -13,9 +13,15 @@ import { PrismaService } from 'src/prisma/prisma.service';
import {
DUPLICATE_EMAIL,
INVALID_EMAIL,
ONLY_ONE_ADMIN_ACCOUNT,
USER_ALREADY_INVITED,
USER_INVITATION_DELETION_FAILED,
USER_NOT_FOUND,
} from '../errors';
import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import * as E from 'fp-ts/Either';
const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>();
@@ -27,6 +33,7 @@ const mockTeamInvitationService = mockDeep<TeamInvitationService>();
const mockTeamCollectionService = mockDeep<TeamCollectionService>();
const mockMailerService = mockDeep<MailerService>();
const mockShortcodeService = mockDeep<ShortcodeService>();
const mockConfigService = mockDeep<ConfigService>();
const adminService = new AdminService(
mockUserService,
@@ -39,6 +46,7 @@ const adminService = new AdminService(
mockPrisma as any,
mockMailerService,
mockShortcodeService,
mockConfigService,
);
const invitedUsers: InvitedUsers[] = [
@@ -55,20 +63,81 @@ const invitedUsers: InvitedUsers[] = [
invitedOn: new Date(),
},
];
const dbAdminUsers: DbUser[] = [
{
uid: 'uid 1',
displayName: 'displayName',
email: 'email@email.com',
photoURL: 'photoURL',
isAdmin: true,
refreshToken: 'refreshToken',
currentRESTSession: '',
currentGQLSession: '',
lastLoggedOn: new Date(),
lastActiveOn: new Date(),
createdOn: new Date(),
},
{
uid: 'uid 2',
displayName: 'displayName',
email: 'email@email.com',
photoURL: 'photoURL',
isAdmin: true,
refreshToken: 'refreshToken',
currentRESTSession: '',
currentGQLSession: '',
lastLoggedOn: new Date(),
lastActiveOn: new Date(),
createdOn: new Date(),
},
];
describe('AdminService', () => {
describe('fetchInvitedUsers', () => {
test('should resolve right and return an array of invited users', async () => {
test('should resolve right and apply pagination correctly', async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]);
// @ts-ignore
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
const results = await adminService.fetchInvitedUsers();
const paginationArgs: OffsetPaginationArgs = { take: 5, skip: 2 };
const results = await adminService.fetchInvitedUsers(paginationArgs);
expect(mockPrisma.invitedUsers.findMany).toHaveBeenCalledWith({
...paginationArgs,
orderBy: {
invitedOn: 'desc',
},
where: {
NOT: {
inviteeEmail: {
in: [dbAdminUsers[0].email],
mode: 'insensitive',
},
},
},
});
});
test('should resolve right and return an array of invited users', async () => {
const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 };
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]);
// @ts-ignore
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
const results = await adminService.fetchInvitedUsers(paginationArgs);
expect(results).toEqual(invitedUsers);
});
test('should resolve left and return an empty array if invited users not found', async () => {
const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 };
mockPrisma.invitedUsers.findMany.mockResolvedValue([]);
const results = await adminService.fetchInvitedUsers();
const results = await adminService.fetchInvitedUsers(paginationArgs);
expect(results).toEqual([]);
});
});
@@ -131,6 +200,61 @@ describe('AdminService', () => {
});
});
describe('revokeUserInvitations', () => {
test('should resolve left and return error if email not invited', async () => {
mockPrisma.invitedUsers.deleteMany.mockRejectedValueOnce(
'RecordNotFound',
);
const result = await adminService.revokeUserInvitations([
'test@gmail.com',
]);
expect(result).toEqualLeft(USER_INVITATION_DELETION_FAILED);
});
test('should resolve right and return deleted invitee email', async () => {
const adminUid = 'adminUid';
mockPrisma.invitedUsers.deleteMany.mockResolvedValueOnce({ count: 1 });
const result = await adminService.revokeUserInvitations([
invitedUsers[0].inviteeEmail,
]);
expect(mockPrisma.invitedUsers.deleteMany).toHaveBeenCalledWith({
where: {
inviteeEmail: {
in: [invitedUsers[0].inviteeEmail],
mode: 'insensitive',
},
},
});
expect(result).toEqualRight(true);
});
});
describe('removeUsersAsAdmin', () => {
test('should resolve right and make admins to users', async () => {
mockUserService.fetchAdminUsers.mockResolvedValueOnce(dbAdminUsers);
mockUserService.removeUsersAsAdmin.mockResolvedValueOnce(E.right(true));
return expect(
await adminService.demoteUsersByAdmin([dbAdminUsers[0].uid]),
).toEqualRight(true);
});
test('should resolve left and return error if only one admin in the infra', async () => {
mockUserService.fetchAdminUsers.mockResolvedValueOnce(dbAdminUsers);
mockUserService.removeUsersAsAdmin.mockResolvedValueOnce(E.right(true));
return expect(
await adminService.demoteUsersByAdmin(
dbAdminUsers.map((user) => user.uid),
),
).toEqualLeft(ONLY_ONE_ADMIN_ACCOUNT);
});
});
describe('getUsersCount', () => {
test('should return count of all users in the organization', async () => {
mockUserService.getUsersCount.mockResolvedValueOnce(10);

View File

@@ -6,13 +6,16 @@ import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option';
import { validateEmail } from '../utils';
import {
ADMIN_CAN_NOT_BE_DELETED,
DUPLICATE_EMAIL,
EMAIL_FAILED,
INVALID_EMAIL,
ONLY_ONE_ADMIN_ACCOUNT,
TEAM_INVITE_ALREADY_MEMBER,
TEAM_INVITE_NO_INVITE_FOUND,
USERS_NOT_FOUND,
USER_ALREADY_INVITED,
USER_INVITATION_DELETION_FAILED,
USER_IS_ADMIN,
USER_NOT_FOUND,
} from '../errors';
@@ -25,6 +28,9 @@ import { TeamEnvironmentsService } from '../team-environments/team-environments.
import { TeamInvitationService } from '../team-invitation/team-invitation.service';
import { TeamMemberRole } from '../team/team.model';
import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import { UserDeletionResult } from 'src/user/user.model';
@Injectable()
export class AdminService {
@@ -39,6 +45,7 @@ export class AdminService {
private readonly prisma: PrismaService,
private readonly mailerService: MailerService,
private readonly shortcodeService: ShortcodeService,
private readonly configService: ConfigService,
) {}
/**
@@ -46,12 +53,30 @@ export class AdminService {
* @param cursorID Users uid
* @param take number of users to fetch
* @returns an Either of array of user or error
* @deprecated use fetchUsersV2 instead
*/
async fetchUsers(cursorID: string, take: number) {
const allUsers = await this.userService.fetchAllUsers(cursorID, take);
return allUsers;
}
/**
* Fetch all the users in the infra.
* @param searchString search on users displayName or email
* @param paginationOption pagination options
* @returns an Either of array of user or error
*/
async fetchUsersV2(
searchString: string,
paginationOption: OffsetPaginationArgs,
) {
const allUsers = await this.userService.fetchAllUsersV2(
searchString,
paginationOption,
);
return allUsers;
}
/**
* Invite a user to join the infra.
* @param adminUID Admin's UID
@@ -64,12 +89,17 @@ export class AdminService {
adminEmail: string,
inviteeEmail: string,
) {
if (inviteeEmail == adminEmail) return E.left(DUPLICATE_EMAIL);
if (inviteeEmail.toLowerCase() == adminEmail.toLowerCase()) {
return E.left(DUPLICATE_EMAIL);
}
if (!validateEmail(inviteeEmail)) return E.left(INVALID_EMAIL);
const alreadyInvitedUser = await this.prisma.invitedUsers.findFirst({
where: {
inviteeEmail: inviteeEmail,
inviteeEmail: {
equals: inviteeEmail,
mode: 'insensitive',
},
},
});
if (alreadyInvitedUser != null) return E.left(USER_ALREADY_INVITED);
@@ -79,7 +109,7 @@ export class AdminService {
template: 'user-invitation',
variables: {
inviteeEmail: inviteeEmail,
magicLink: `${process.env.VITE_BASE_URL}`,
magicLink: `${this.configService.get('VITE_BASE_URL')}`,
},
});
} catch (e) {
@@ -108,14 +138,76 @@ export class AdminService {
return E.right(invitedUser);
}
/**
* Update the display name of a user
* @param userUid Who's display name is being updated
* @param displayName New display name of the user
* @returns an Either of boolean or error
*/
async updateUserDisplayName(userUid: string, displayName: string) {
const updatedUser = await this.userService.updateUserDisplayName(
userUid,
displayName,
);
if (E.isLeft(updatedUser)) return E.left(updatedUser.left);
return E.right(true);
}
/**
* Revoke infra level user invitations
* @param inviteeEmails Invitee's emails
* @param adminUid Admin Uid
* @returns an Either of boolean or error string
*/
async revokeUserInvitations(inviteeEmails: string[]) {
const areAllEmailsValid = inviteeEmails.every((email) =>
validateEmail(email),
);
if (!areAllEmailsValid) {
return E.left(INVALID_EMAIL);
}
try {
await this.prisma.invitedUsers.deleteMany({
where: {
inviteeEmail: { in: inviteeEmails, mode: 'insensitive' },
},
});
return E.right(true);
} catch (error) {
return E.left(USER_INVITATION_DELETION_FAILED);
}
}
/**
* Fetch the list of invited users by the admin.
* @returns an Either of array of `InvitedUser` object or error
*/
async fetchInvitedUsers() {
const invitedUsers = await this.prisma.invitedUsers.findMany();
async fetchInvitedUsers(paginationOption: OffsetPaginationArgs) {
const userEmailObjs = await this.prisma.user.findMany({
select: {
email: true,
},
});
const users: InvitedUser[] = invitedUsers.map(
const pendingInvitedUsers = await this.prisma.invitedUsers.findMany({
take: paginationOption.take,
skip: paginationOption.skip,
orderBy: {
invitedOn: 'desc',
},
where: {
NOT: {
inviteeEmail: {
in: userEmailObjs.map((user) => user.email),
mode: 'insensitive',
},
},
},
});
const users: InvitedUser[] = pendingInvitedUsers.map(
(user) => <InvitedUser>{ ...user },
);
@@ -335,6 +427,7 @@ export class AdminService {
* Remove a user account by UID
* @param userUid User UID
* @returns an Either of boolean or error
* @deprecated use removeUserAccounts instead
*/
async removeUserAccount(userUid: string) {
const user = await this.userService.findUserById(userUid);
@@ -347,10 +440,73 @@ export class AdminService {
return E.right(delUser.right);
}
/**
* Remove user (not Admin) accounts by UIDs
* @param userUIDs User UIDs
* @returns an Either of boolean or error
*/
async removeUserAccounts(userUIDs: string[]) {
const userDeleteResult: UserDeletionResult[] = [];
// step 1: fetch all users
const allUsersList = await this.userService.findUsersByIds(userUIDs);
if (allUsersList.length === 0) return E.left(USERS_NOT_FOUND);
// step 2: admin user can not be deleted without removing admin status/role
allUsersList.forEach((user) => {
if (user.isAdmin) {
userDeleteResult.push({
userUID: user.uid,
isDeleted: false,
errorMessage: ADMIN_CAN_NOT_BE_DELETED,
});
}
});
const nonAdminUsers = allUsersList.filter((user) => !user.isAdmin);
let deletedUserEmails: string[] = [];
// step 3: delete non-admin users
const deletionPromises = nonAdminUsers.map((user) => {
return this.userService
.deleteUserByUID(user)()
.then((res) => {
if (E.isLeft(res)) {
return {
userUID: user.uid,
isDeleted: false,
errorMessage: res.left,
} as UserDeletionResult;
}
deletedUserEmails.push(user.email);
return {
userUID: user.uid,
isDeleted: true,
errorMessage: null,
} as UserDeletionResult;
});
});
const promiseResult = await Promise.allSettled(deletionPromises);
// step 4: revoke all the invites sent to the deleted users
await this.revokeUserInvitations(deletedUserEmails);
// step 5: return the result
promiseResult.forEach((result) => {
if (result.status === 'fulfilled') {
userDeleteResult.push(result.value);
}
});
return E.right(userDeleteResult);
}
/**
* Make a user an admin
* @param userUid User UID
* @returns an Either of boolean or error
* @deprecated use makeUsersAdmin instead
*/
async makeUserAdmin(userUID: string) {
const admin = await this.userService.makeAdmin(userUID);
@@ -358,10 +514,22 @@ export class AdminService {
return E.right(true);
}
/**
* Make users to admin
* @param userUid User UIDs
* @returns an Either of boolean or error
*/
async makeUsersAdmin(userUIDs: string[]) {
const isUpdated = await this.userService.makeAdmins(userUIDs);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(true);
}
/**
* Remove user as admin
* @param userUid User UID
* @returns an Either of boolean or error
* @deprecated use demoteUsersByAdmin instead
*/
async removeUserAsAdmin(userUID: string) {
const adminUsers = await this.userService.fetchAdminUsers();
@@ -372,6 +540,26 @@ export class AdminService {
return E.right(true);
}
/**
* Remove users as admin
* @param userUIDs User UIDs
* @returns an Either of boolean or error
*/
async demoteUsersByAdmin(userUIDs: string[]) {
const adminUsers = await this.userService.fetchAdminUsers();
const remainingAdmins = adminUsers.filter(
(adminUser) => !userUIDs.includes(adminUser.uid),
);
if (remainingAdmins.length < 1) {
return E.left(ONLY_ONE_ADMIN_ACCOUNT);
}
const isUpdated = await this.userService.removeUsersAsAdmin(userUIDs);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(isUpdated.right);
}
/**
* Fetch list of all the Users in org
* @returns number of users in the org

View File

@@ -0,0 +1,11 @@
import { Injectable, ExecutionContext, CanActivate } from '@nestjs/common';
@Injectable()
export class RESTAdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
return user.isAdmin;
}
}

View File

@@ -1,5 +1,12 @@
import { UseGuards } from '@nestjs/common';
import { Args, ID, Query, ResolveField, Resolver } from '@nestjs/graphql';
import {
Args,
ID,
Mutation,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { Infra } from './infra.model';
import { AdminService } from './admin.service';
@@ -10,17 +17,31 @@ import { AuthUser } from 'src/types/AuthUser';
import { throwErr } from 'src/utils';
import * as E from 'fp-ts/Either';
import { Admin } from './admin.model';
import { PaginationArgs } from 'src/types/input-types.args';
import {
OffsetPaginationArgs,
PaginationArgs,
} from 'src/types/input-types.args';
import { InvitedUser } from './invited-user.model';
import { Team } from 'src/team/team.model';
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
import { GqlAdmin } from './decorators/gql-admin.decorator';
import { ShortcodeWithUserEmail } from 'src/shortcode/shortcode.model';
import { InfraConfig } from 'src/infra-config/infra-config.model';
import { InfraConfigService } from 'src/infra-config/infra-config.service';
import {
EnableAndDisableSSOArgs,
InfraConfigArgs,
} from 'src/infra-config/input-args';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { ServiceStatus } from 'src/infra-config/helper';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => Infra)
export class InfraResolver {
constructor(private adminService: AdminService) {}
constructor(
private adminService: AdminService,
private infraConfigService: InfraConfigService,
) {}
@Query(() => Infra, {
description: 'Fetch details of the Infrastructure',
@@ -59,6 +80,7 @@ export class InfraResolver {
@ResolveField(() => [User], {
description: 'Returns a list of all the users in infra',
deprecationReason: 'Use allUsersV2 instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async allUsers(@Args() args: PaginationArgs): Promise<AuthUser[]> {
@@ -66,11 +88,33 @@ export class InfraResolver {
return users;
}
@ResolveField(() => [User], {
description: 'Returns a list of all the users in infra',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async allUsersV2(
@Args({
name: 'searchString',
nullable: true,
description: 'Search on users displayName or email',
})
searchString: string,
@Args() paginationOption: OffsetPaginationArgs,
): Promise<AuthUser[]> {
const users = await this.adminService.fetchUsersV2(
searchString,
paginationOption,
);
return users;
}
@ResolveField(() => [InvitedUser], {
description: 'Returns a list of all the invited users',
})
async invitedUsers(): Promise<InvitedUser[]> {
const users = await this.adminService.fetchInvitedUsers();
async invitedUsers(
@Args() args: OffsetPaginationArgs,
): Promise<InvitedUser[]> {
const users = await this.adminService.fetchInvitedUsers(args);
return users;
}
@@ -222,4 +266,116 @@ export class InfraResolver {
userEmail,
);
}
@Query(() => [InfraConfig], {
description: 'Retrieve configuration details for the instance',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async infraConfigs(
@Args({
name: 'configNames',
type: () => [InfraConfigEnum],
description: 'Configs to fetch',
})
names: InfraConfigEnum[],
) {
const infraConfigs = await this.infraConfigService.getMany(names);
if (E.isLeft(infraConfigs)) throwErr(infraConfigs.left);
return infraConfigs.right;
}
@Query(() => [String], {
description: 'Allowed Auth Provider list',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
allowedAuthProviders() {
return this.infraConfigService.getAllowedAuthProviders();
}
/* Mutations */
@Mutation(() => [InfraConfig], {
description: 'Update Infra Configs',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async updateInfraConfigs(
@Args({
name: 'infraConfigs',
type: () => [InfraConfigArgs],
description: 'InfraConfigs to update',
})
infraConfigs: InfraConfigArgs[],
) {
const updatedRes = await this.infraConfigService.updateMany(infraConfigs);
if (E.isLeft(updatedRes)) throwErr(updatedRes.left);
return updatedRes.right;
}
@Mutation(() => Boolean, {
description: 'Enable or disable analytics collection',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async toggleAnalyticsCollection(
@Args({
name: 'status',
type: () => ServiceStatus,
description: 'Toggle analytics collection',
})
analyticsCollectionStatus: ServiceStatus,
) {
const res = await this.infraConfigService.toggleAnalyticsCollection(
analyticsCollectionStatus,
);
if (E.isLeft(res)) throwErr(res.left);
return res.right;
}
@Mutation(() => Boolean, {
description: 'Reset Infra Configs with default values (.env)',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async resetInfraConfigs() {
const resetRes = await this.infraConfigService.reset();
if (E.isLeft(resetRes)) throwErr(resetRes.left);
return true;
}
@Mutation(() => Boolean, {
description: 'Enable or Disable SSO for login/signup',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async enableAndDisableSSO(
@Args({
name: 'providerInfo',
type: () => [EnableAndDisableSSOArgs],
description: 'SSO provider and status',
})
providerInfo: EnableAndDisableSSOArgs[],
) {
const isUpdated = await this.infraConfigService.enableAndDisableSSO(
providerInfo,
);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return true;
}
@Mutation(() => Boolean, {
description: 'Enable or Disable SMTP for sending emails',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async toggleSMTP(
@Args({
name: 'status',
type: () => ServiceStatus,
description: 'Toggle SMTP',
})
status: ServiceStatus,
) {
const isUpdated = await this.infraConfigService.enableAndDisableSMTP(
status,
);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return true;
}
}

View File

@@ -20,51 +20,75 @@ import { ShortcodeModule } from './shortcode/shortcode.module';
import { COOKIES_NOT_FOUND } from './errors';
import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { InfraConfigModule } from './infra-config/infra-config.module';
import { loadInfraConfiguration } from './infra-config/helper';
import { MailerModule } from './mailer/mailer.module';
import { PosthogModule } from './posthog/posthog.module';
import { ScheduleModule } from '@nestjs/schedule';
import { HealthModule } from './health/health.module';
import { AccessTokenModule } from './access-token/access-token.module';
import { UserLastActiveOnInterceptor } from './interceptors/user-last-active-on.interceptor';
import { InfraTokenModule } from './infra-token/infra-token.module';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
buildSchemaOptions: {
numberScalarMode: 'integer',
},
playground: process.env.PRODUCTION !== 'true',
autoSchemaFile: true,
installSubscriptionHandlers: true,
subscriptions: {
'subscriptions-transport-ws': {
path: '/graphql',
onConnect: (_, websocket) => {
try {
const cookies = subscriptionContextCookieParser(
websocket.upgradeReq.headers.cookie,
);
return {
headers: { ...websocket?.upgradeReq?.headers, cookies },
};
} catch (error) {
throw new HttpException(COOKIES_NOT_FOUND, 400, {
cause: new Error(COOKIES_NOT_FOUND),
});
}
},
},
},
context: ({ req, res, connection }) => ({
req,
res,
connection,
}),
driver: ApolloDriver,
ConfigModule.forRoot({
isGlobal: true,
load: [async () => loadInfraConfiguration()],
}),
ThrottlerModule.forRoot([
{
ttl: +process.env.RATE_LIMIT_TTL,
limit: +process.env.RATE_LIMIT_MAX,
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
return {
buildSchemaOptions: {
numberScalarMode: 'integer',
},
playground: configService.get('PRODUCTION') !== 'true',
autoSchemaFile: true,
installSubscriptionHandlers: true,
subscriptions: {
'subscriptions-transport-ws': {
path: '/graphql',
onConnect: (_, websocket) => {
try {
const cookies = subscriptionContextCookieParser(
websocket.upgradeReq.headers.cookie,
);
return {
headers: { ...websocket?.upgradeReq?.headers, cookies },
};
} catch (error) {
throw new HttpException(COOKIES_NOT_FOUND, 400, {
cause: new Error(COOKIES_NOT_FOUND),
});
}
},
},
},
context: ({ req, res, connection }) => ({
req,
res,
connection,
}),
};
},
]),
}),
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => [
{
ttl: +configService.get('RATE_LIMIT_TTL'),
limit: +configService.get('RATE_LIMIT_MAX'),
},
],
}),
MailerModule.register(),
UserModule,
AuthModule,
AuthModule.register(),
AdminModule,
UserSettingsModule,
UserEnvironmentsModule,
@@ -77,8 +101,17 @@ import { AppController } from './app.controller';
TeamInvitationModule,
UserCollectionModule,
ShortcodeModule,
InfraConfigModule,
PosthogModule,
ScheduleModule.forRoot(),
HealthModule,
AccessTokenModule,
InfraTokenModule,
],
providers: [
GQLComplexityPlugin,
{ provide: 'APP_INTERCEPTOR', useClass: UserLastActiveOnInterceptor },
],
providers: [GQLComplexityPlugin],
controllers: [AppController],
})
export class AppModule {}

View File

@@ -2,12 +2,12 @@ import {
Body,
Controller,
Get,
InternalServerErrorException,
Post,
Query,
Request,
Res,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { SignInMagicDto } from './dto/signin-magic.dto';
@@ -19,23 +19,30 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { GqlUser } from 'src/decorators/gql-user.decorator';
import { AuthUser } from 'src/types/AuthUser';
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
import {
AuthProvider,
authCookieHandler,
authProviderCheck,
throwHTTPErr,
} from './helper';
import { AuthProvider, authCookieHandler, authProviderCheck } from './helper';
import { GoogleSSOGuard } from './guards/google-sso.guard';
import { GithubSSOGuard } from './guards/github-sso.guard';
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import { SkipThrottle } from '@nestjs/throttler';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
import { UserLastLoginInterceptor } from 'src/interceptors/user-last-login.interceptor';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'auth', version: '1' })
export class AuthController {
constructor(private authService: AuthService) {}
constructor(
private authService: AuthService,
private configService: ConfigService,
) {}
@Get('providers')
async getAuthProviders() {
const providers = await this.authService.getAuthProviders();
return { providers };
}
/**
** Route to initiate magic-link auth for a users email
@@ -45,8 +52,14 @@ export class AuthController {
@Body() authData: SignInMagicDto,
@Query('origin') origin: string,
) {
if (!authProviderCheck(AuthProvider.EMAIL))
if (
!authProviderCheck(
AuthProvider.EMAIL,
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
)
) {
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
}
const deviceIdToken = await this.authService.signInMagicLink(
authData.email,
@@ -99,6 +112,7 @@ export class AuthController {
@Get('google/callback')
@SkipThrottle()
@UseGuards(GoogleSSOGuard)
@UseInterceptors(UserLastLoginInterceptor)
async googleAuthRedirect(@Request() req, @Res() res) {
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
@@ -124,6 +138,7 @@ export class AuthController {
@Get('github/callback')
@SkipThrottle()
@UseGuards(GithubSSOGuard)
@UseInterceptors(UserLastLoginInterceptor)
async githubAuthRedirect(@Request() req, @Res() res) {
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
@@ -149,6 +164,7 @@ export class AuthController {
@Get('microsoft/callback')
@SkipThrottle()
@UseGuards(MicrosoftSSOGuard)
@UseInterceptors(UserLastLoginInterceptor)
async microsoftAuthRedirect(@Request() req, @Res() res) {
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);

View File

@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from 'src/user/user.module';
import { MailerModule } from 'src/mailer/mailer.module';
import { PrismaModule } from 'src/prisma/prisma.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
@@ -12,25 +11,55 @@ import { GoogleStrategy } from './strategies/google.strategy';
import { GithubStrategy } from './strategies/github.strategy';
import { MicrosoftStrategy } from './strategies/microsoft.strategy';
import { AuthProvider, authProviderCheck } from './helper';
import { ConfigModule, ConfigService } from '@nestjs/config';
import {
isInfraConfigTablePopulated,
loadInfraConfiguration,
} from 'src/infra-config/helper';
import { InfraConfigModule } from 'src/infra-config/infra-config.module';
@Module({
imports: [
PrismaModule,
UserModule,
MailerModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET'),
}),
}),
InfraConfigModule,
],
providers: [
AuthService,
JwtStrategy,
RTJwtStrategy,
...(authProviderCheck(AuthProvider.GOOGLE) ? [GoogleStrategy] : []),
...(authProviderCheck(AuthProvider.GITHUB) ? [GithubStrategy] : []),
...(authProviderCheck(AuthProvider.MICROSOFT) ? [MicrosoftStrategy] : []),
],
providers: [AuthService, JwtStrategy, RTJwtStrategy],
controllers: [AuthController],
})
export class AuthModule {}
export class AuthModule {
static async register() {
const isInfraConfigPopulated = await isInfraConfigTablePopulated();
if (!isInfraConfigPopulated) {
return { module: AuthModule };
}
const env = await loadInfraConfiguration();
const allowedAuthProviders = env.INFRA.VITE_ALLOWED_AUTH_PROVIDERS;
const providers = [
...(authProviderCheck(AuthProvider.GOOGLE, allowedAuthProviders)
? [GoogleStrategy]
: []),
...(authProviderCheck(AuthProvider.GITHUB, allowedAuthProviders)
? [GithubStrategy]
: []),
...(authProviderCheck(AuthProvider.MICROSOFT, allowedAuthProviders)
? [MicrosoftStrategy]
: []),
];
return {
module: AuthModule,
providers,
};
}
}

View File

@@ -21,15 +21,26 @@ import { VerifyMagicDto } from './dto/verify-magic.dto';
import { DateTime } from 'luxon';
import * as argon2 from 'argon2';
import * as E from 'fp-ts/Either';
import { ConfigService } from '@nestjs/config';
import { InfraConfigService } from 'src/infra-config/infra-config.service';
const mockPrisma = mockDeep<PrismaService>();
const mockUser = mockDeep<UserService>();
const mockJWT = mockDeep<JwtService>();
const mockMailer = mockDeep<MailerService>();
const mockConfigService = mockDeep<ConfigService>();
const mockInfraConfigService = mockDeep<InfraConfigService>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const authService = new AuthService(mockUser, mockPrisma, mockJWT, mockMailer);
const authService = new AuthService(
mockUser,
mockPrisma,
mockJWT,
mockMailer,
mockConfigService,
mockInfraConfigService,
);
const currentTime = new Date();
@@ -40,6 +51,8 @@ const user: AuthUser = {
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false,
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
lastActiveOn: currentTime,
createdOn: currentTime,
currentGQLSession: {},
currentRESTSession: {},
@@ -91,6 +104,8 @@ describe('signInMagicLink', () => {
mockUser.createUserViaMagicLink.mockResolvedValue(user);
// create new entry in VerificationToken table
mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData);
// Read env variable 'MAGIC_LINK_TOKEN_VALIDITY' from config service
mockConfigService.get.mockReturnValue('3');
const result = await authService.signInMagicLink(
'dwight@dundermifflin.com',
@@ -159,9 +174,11 @@ describe('verifyMagicLinkTokens', () => {
// generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
// deletePasswordlessVerificationToken
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
// usersService.updateUserLastLoggedOn
mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true));
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualRight({
@@ -184,9 +201,11 @@ describe('verifyMagicLinkTokens', () => {
// generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
// deletePasswordlessVerificationToken
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
// usersService.updateUserLastLoggedOn
mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true));
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualRight({
@@ -226,7 +245,7 @@ describe('verifyMagicLinkTokens', () => {
// generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
E.left(USER_NOT_FOUND),
);
@@ -251,7 +270,7 @@ describe('verifyMagicLinkTokens', () => {
// generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
// deletePasswordlessVerificationToken
mockPrisma.verificationToken.delete.mockRejectedValueOnce('RecordNotFound');
@@ -267,7 +286,7 @@ describe('generateAuthTokens', () => {
test('Should successfully generate tokens with valid inputs', async () => {
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
const result = await authService.generateAuthTokens(user.uid);
expect(result).toEqualRight({
@@ -279,7 +298,7 @@ describe('generateAuthTokens', () => {
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
E.left(USER_NOT_FOUND),
);
@@ -306,7 +325,7 @@ describe('refreshAuthTokens', () => {
// generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
E.left(USER_NOT_FOUND),
);
@@ -335,7 +354,7 @@ describe('refreshAuthTokens', () => {
// generateAuthTokens
mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
E.right({
...user,
refreshToken: 'sdhjcbjsdhcbshjdcb',

View File

@@ -24,10 +24,12 @@ import {
RefreshTokenPayload,
} from 'src/types/AuthTokens';
import { JwtService } from '@nestjs/jwt';
import { AuthError } from 'src/types/AuthError';
import { RESTError } from 'src/types/RESTError';
import { AuthUser, IsAdmin } from 'src/types/AuthUser';
import { VerificationToken } from '@prisma/client';
import { Origin } from './helper';
import { ConfigService } from '@nestjs/config';
import { InfraConfigService } from 'src/infra-config/infra-config.service';
@Injectable()
export class AuthService {
@@ -36,6 +38,8 @@ export class AuthService {
private prismaService: PrismaService,
private jwtService: JwtService,
private readonly mailerService: MailerService,
private readonly configService: ConfigService,
private infraConfigService: InfraConfigService,
) {}
/**
@@ -46,10 +50,12 @@ export class AuthService {
*/
private async generateMagicLinkTokens(user: AuthUser) {
const salt = await bcrypt.genSalt(
parseInt(process.env.TOKEN_SALT_COMPLEXITY),
parseInt(this.configService.get('TOKEN_SALT_COMPLEXITY')),
);
const expiresOn = DateTime.now()
.plus({ hours: parseInt(process.env.MAGIC_LINK_TOKEN_VALIDITY) })
.plus({
hours: parseInt(this.configService.get('MAGIC_LINK_TOKEN_VALIDITY')),
})
.toISO()
.toString();
@@ -95,23 +101,23 @@ export class AuthService {
*/
private async generateRefreshToken(userUid: string) {
const refreshTokenPayload: RefreshTokenPayload = {
iss: process.env.VITE_BASE_URL,
iss: this.configService.get('VITE_BASE_URL'),
sub: userUid,
aud: [process.env.VITE_BASE_URL],
aud: [this.configService.get('VITE_BASE_URL')],
};
const refreshToken = await this.jwtService.sign(refreshTokenPayload, {
expiresIn: process.env.REFRESH_TOKEN_VALIDITY, //7 Days
expiresIn: this.configService.get('REFRESH_TOKEN_VALIDITY'), //7 Days
});
const refreshTokenHash = await argon2.hash(refreshToken);
const updatedUser = await this.usersService.UpdateUserRefreshToken(
const updatedUser = await this.usersService.updateUserRefreshToken(
refreshTokenHash,
userUid,
);
if (E.isLeft(updatedUser))
return E.left(<AuthError>{
return E.left(<RESTError>{
message: updatedUser.left,
statusCode: HttpStatus.NOT_FOUND,
});
@@ -127,9 +133,9 @@ export class AuthService {
*/
async generateAuthTokens(userUid: string) {
const accessTokenPayload: AccessTokenPayload = {
iss: process.env.VITE_BASE_URL,
iss: this.configService.get('VITE_BASE_URL'),
sub: userUid,
aud: [process.env.VITE_BASE_URL],
aud: [this.configService.get('VITE_BASE_URL')],
};
const refreshToken = await this.generateRefreshToken(userUid);
@@ -137,7 +143,7 @@ export class AuthService {
return E.right(<AuthTokens>{
access_token: await this.jwtService.sign(accessTokenPayload, {
expiresIn: process.env.ACCESS_TOKEN_VALIDITY, //1 Day
expiresIn: this.configService.get('ACCESS_TOKEN_VALIDITY'), //1 Day
}),
refresh_token: refreshToken.right,
});
@@ -218,14 +224,14 @@ export class AuthService {
let url: string;
switch (origin) {
case Origin.ADMIN:
url = process.env.VITE_ADMIN_URL;
url = this.configService.get('VITE_ADMIN_URL');
break;
case Origin.APP:
url = process.env.VITE_BASE_URL;
url = this.configService.get('VITE_BASE_URL');
break;
default:
// if origin is invalid by default set URL to Hoppscotch-App
url = process.env.VITE_BASE_URL;
url = this.configService.get('VITE_BASE_URL');
}
await this.mailerService.sendEmail(email, {
@@ -249,7 +255,7 @@ export class AuthService {
*/
async verifyMagicLinkTokens(
magicLinkIDTokens: VerifyMagicDto,
): Promise<E.Right<AuthTokens> | E.Left<AuthError>> {
): Promise<E.Right<AuthTokens> | E.Left<RESTError>> {
const passwordlessTokens = await this.validatePasswordlessTokens(
magicLinkIDTokens,
);
@@ -314,6 +320,8 @@ export class AuthService {
statusCode: HttpStatus.NOT_FOUND,
});
this.usersService.updateUserLastLoggedOn(passwordlessTokens.value.userUid);
return E.right(tokens.right);
}
@@ -367,7 +375,7 @@ export class AuthService {
if (usersCount === 1) {
const elevatedUser = await this.usersService.makeAdmin(user.uid);
if (E.isLeft(elevatedUser))
return E.left(<AuthError>{
return E.left(<RESTError>{
message: elevatedUser.left,
statusCode: HttpStatus.NOT_FOUND,
});
@@ -377,4 +385,8 @@ export class AuthService {
return E.right(<IsAdmin>{ isAdmin: false });
}
getAuthProviders() {
return this.infraConfigService.getAllowedAuthProviders();
}
}

View File

@@ -1,16 +1,28 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { AuthProvider, authProviderCheck } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
@Injectable()
export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {
constructor(private readonly configService: ConfigService) {
super();
}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
if (!authProviderCheck(AuthProvider.GITHUB))
if (
!authProviderCheck(
AuthProvider.GITHUB,
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
)
) {
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
}
return super.canActivate(context);
}

View File

@@ -1,16 +1,28 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { AuthProvider, authProviderCheck } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
@Injectable()
export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {
constructor(private readonly configService: ConfigService) {
super();
}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
if (!authProviderCheck(AuthProvider.GOOGLE))
if (
!authProviderCheck(
AuthProvider.GOOGLE,
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
)
) {
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
}
return super.canActivate(context);
}

View File

@@ -1,22 +1,34 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { AuthProvider, authProviderCheck } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
@Injectable()
export class MicrosoftSSOGuard
extends AuthGuard('microsoft')
implements CanActivate
{
constructor(private readonly configService: ConfigService) {
super();
}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
if (!authProviderCheck(AuthProvider.MICROSOFT))
if (
!authProviderCheck(
AuthProvider.MICROSOFT,
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
)
) {
throwHTTPErr({
message: AUTH_PROVIDER_NOT_SPECIFIED,
statusCode: 404,
});
}
return super.canActivate(context);
}

View File

@@ -1,11 +1,11 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { DateTime } from 'luxon';
import { AuthError } from 'src/types/AuthError';
import { AuthTokens } from 'src/types/AuthTokens';
import { Response } from 'express';
import * as cookie from 'cookie';
import { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors';
import { throwErr } from 'src/utils';
import { ConfigService } from '@nestjs/config';
enum AuthTokenType {
ACCESS_TOKEN = 'access_token',
@@ -24,15 +24,6 @@ export enum AuthProvider {
EMAIL = 'EMAIL',
}
/**
* This function allows throw to be used as an expression
* @param errMessage Message present in the error message
*/
export function throwHTTPErr(errorData: AuthError): never {
const { message, statusCode } = errorData;
throw new HttpException(message, statusCode);
}
/**
* Sets and returns the cookies in the response object on successful authentication
* @param res Express Response Object
@@ -45,27 +36,29 @@ export const authCookieHandler = (
redirect: boolean,
redirectUrl: string | null,
) => {
const configService = new ConfigService();
const currentTime = DateTime.now();
const accessTokenValidity = currentTime
.plus({
milliseconds: parseInt(process.env.ACCESS_TOKEN_VALIDITY),
milliseconds: parseInt(configService.get('ACCESS_TOKEN_VALIDITY')),
})
.toMillis();
const refreshTokenValidity = currentTime
.plus({
milliseconds: parseInt(process.env.REFRESH_TOKEN_VALIDITY),
milliseconds: parseInt(configService.get('REFRESH_TOKEN_VALIDITY')),
})
.toMillis();
res.cookie(AuthTokenType.ACCESS_TOKEN, authTokens.access_token, {
httpOnly: true,
secure: true,
secure: configService.get('ALLOW_SECURE_COOKIES') === 'true',
sameSite: 'lax',
maxAge: accessTokenValidity,
});
res.cookie(AuthTokenType.REFRESH_TOKEN, authTokens.refresh_token, {
httpOnly: true,
secure: true,
secure: configService.get('ALLOW_SECURE_COOKIES') === 'true',
sameSite: 'lax',
maxAge: refreshTokenValidity,
});
@@ -75,10 +68,12 @@ export const authCookieHandler = (
}
// check to see if redirectUrl is a whitelisted url
const whitelistedOrigins = process.env.WHITELISTED_ORIGINS.split(',');
const whitelistedOrigins = configService
.get('WHITELISTED_ORIGINS')
.split(',');
if (!whitelistedOrigins.includes(redirectUrl))
// if it is not redirect by default to REDIRECT_URL
redirectUrl = process.env.REDIRECT_URL;
redirectUrl = configService.get('REDIRECT_URL');
return res.status(HttpStatus.OK).redirect(redirectUrl);
};
@@ -112,13 +107,16 @@ export const subscriptionContextCookieParser = (rawCookies: string) => {
* @param provider Provider we want to check the presence of
* @returns Boolean if provider specified is present or not
*/
export function authProviderCheck(provider: string) {
export function authProviderCheck(
provider: string,
VITE_ALLOWED_AUTH_PROVIDERS: string,
) {
if (!provider) {
throwErr(AUTH_PROVIDER_NOT_SPECIFIED);
}
const envVariables = process.env.VITE_ALLOWED_AUTH_PROVIDERS
? process.env.VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
const envVariables = VITE_ALLOWED_AUTH_PROVIDERS
? VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
provider.trim().toUpperCase(),
)
: [];

View File

@@ -5,18 +5,20 @@ import { AuthService } from '../auth.service';
import { UserService } from 'src/user/user.service';
import * as O from 'fp-ts/Option';
import * as E from 'fp-ts/Either';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class GithubStrategy extends PassportStrategy(Strategy) {
constructor(
private authService: AuthService,
private usersService: UserService,
private configService: ConfigService,
) {
super({
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: process.env.GITHUB_CALLBACK_URL,
scope: [process.env.GITHUB_SCOPE],
clientID: configService.get('INFRA.GITHUB_CLIENT_ID'),
clientSecret: configService.get('INFRA.GITHUB_CLIENT_SECRET'),
callbackURL: configService.get('INFRA.GITHUB_CALLBACK_URL'),
scope: [configService.get('INFRA.GITHUB_SCOPE')],
store: true,
});
}

View File

@@ -5,18 +5,20 @@ import { UserService } from 'src/user/user.service';
import * as O from 'fp-ts/Option';
import { AuthService } from '../auth.service';
import * as E from 'fp-ts/Either';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy) {
constructor(
private usersService: UserService,
private authService: AuthService,
private configService: ConfigService,
) {
super({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL,
scope: process.env.GOOGLE_SCOPE.split(','),
clientID: configService.get('INFRA.GOOGLE_CLIENT_ID'),
clientSecret: configService.get('INFRA.GOOGLE_CLIENT_SECRET'),
callbackURL: configService.get('INFRA.GOOGLE_CALLBACK_URL'),
scope: configService.get('INFRA.GOOGLE_SCOPE').split(','),
passReqToCallback: true,
store: true,
});

View File

@@ -15,10 +15,14 @@ import {
INVALID_ACCESS_TOKEN,
USER_NOT_FOUND,
} from 'src/errors';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(private usersService: UserService) {
constructor(
private usersService: UserService,
private configService: ConfigService,
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => {
@@ -29,7 +33,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
return ATCookie;
},
]),
secretOrKey: process.env.JWT_SECRET,
secretOrKey: configService.get('JWT_SECRET'),
});
}

View File

@@ -5,19 +5,21 @@ import { AuthService } from '../auth.service';
import { UserService } from 'src/user/user.service';
import * as O from 'fp-ts/Option';
import * as E from 'fp-ts/Either';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class MicrosoftStrategy extends PassportStrategy(Strategy) {
constructor(
private authService: AuthService,
private usersService: UserService,
private configService: ConfigService,
) {
super({
clientID: process.env.MICROSOFT_CLIENT_ID,
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
callbackURL: process.env.MICROSOFT_CALLBACK_URL,
scope: [process.env.MICROSOFT_SCOPE],
tenant: process.env.MICROSOFT_TENANT,
clientID: configService.get('INFRA.MICROSOFT_CLIENT_ID'),
clientSecret: configService.get('INFRA.MICROSOFT_CLIENT_SECRET'),
callbackURL: configService.get('INFRA.MICROSOFT_CALLBACK_URL'),
scope: [configService.get('INFRA.MICROSOFT_SCOPE')],
tenant: configService.get('INFRA.MICROSOFT_TENANT'),
store: true,
});
}

View File

@@ -14,10 +14,14 @@ import {
USER_NOT_FOUND,
} from 'src/errors';
import * as O from 'fp-ts/Option';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
constructor(private usersService: UserService) {
constructor(
private usersService: UserService,
private configService: ConfigService,
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => {
@@ -28,7 +32,7 @@ export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
return RTCookie;
},
]),
secretOrKey: process.env.JWT_SECRET,
secretOrKey: configService.get('JWT_SECRET'),
});
}

View File

@@ -0,0 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
** Decorator to fetch refresh_token from cookie
*/
export const BearerToken = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const request = context.switchToHttp().getRequest<Request>();
// authorization token will be "Bearer <token>"
const authorization = request.headers['authorization'];
// Remove "Bearer " and return the token only
return authorization.split(' ')[1];
},
);

View File

@@ -10,6 +10,14 @@ export const DUPLICATE_EMAIL = 'email/both_emails_cannot_be_same' as const;
export const ONLY_ONE_ADMIN_ACCOUNT =
'admin/only_one_admin_account_found' as const;
/**
* Admin user can not be deleted
* To delete the admin user, first make the Admin user a normal user
* (AdminService)
*/
export const ADMIN_CAN_NOT_BE_DELETED =
'admin/admin_can_not_be_deleted' as const;
/**
* Token Authorization failed (Check 'Authorization' Header)
* (GqlAuthGuard)
@@ -28,6 +36,13 @@ export const JSON_INVALID = 'json_invalid';
*/
export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified';
/**
* Auth Provider not specified
* (Auth)
*/
export const AUTH_PROVIDER_NOT_CONFIGURED =
'auth/provider_not_configured_correctly';
/**
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file
*/
@@ -69,6 +84,12 @@ export const USER_ALREADY_INVITED = 'admin/user_already_invited' as const;
*/
export const USER_UPDATE_FAILED = 'user/update_failed' as const;
/**
* User display name validation failure
* (UserService)
*/
export const USER_SHORT_DISPLAY_NAME = 'user/short_display_name' as const;
/**
* User deletion failure
* (UserService)
@@ -92,6 +113,13 @@ export const USER_IS_OWNER = 'user/is_owner' as const;
*/
export const USER_IS_ADMIN = 'user/is_admin' as const;
/**
* User invite deletion failure error due to invitation not found
* (AdminService)
*/
export const USER_INVITATION_DELETION_FAILED =
'user/invitation_deletion_failed' as const;
/**
* Teams not found
* (TeamsService)
@@ -206,6 +234,12 @@ export const TEAM_COL_NOT_SAME_PARENT =
export const TEAM_COL_SAME_NEXT_COLL =
'team_coll/collection_and_next_collection_are_same';
/**
* Team Collection search failed
* (TeamCollectionService)
*/
export const TEAM_COL_SEARCH_FAILED = 'team_coll/team_collection_search_failed';
/**
* Team Collection Re-Ordering Failed
* (TeamCollectionService)
@@ -254,6 +288,20 @@ export const TEAM_COLL_INVALID_JSON = 'team_coll/invalid_json';
*/
export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const;
/**
* The Team Collection data is not valid
* (TeamCollectionService)
*/
export const TEAM_COLL_DATA_INVALID =
'team_coll/team_coll_data_invalid' as const;
/**
* Team Collection parent tree generation failed
* (TeamCollectionService)
*/
export const TEAM_COLL_PARENT_TREE_GEN_FAILED =
'team_coll/team_coll_parent_tree_generation_failed';
/**
* Tried to perform an action on a request that doesn't accept their member role level
* (GqlRequestTeamMemberGuard)
@@ -279,6 +327,19 @@ export const TEAM_REQ_INVALID_TARGET_COLL_ID =
*/
export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const;
/**
* Team Request search failed
* (TeamRequestService)
*/
export const TEAM_REQ_SEARCH_FAILED = 'team_req/team_request_search_failed';
/**
* Team Request parent tree generation failed
* (TeamRequestService)
*/
export const TEAM_REQ_PARENT_TREE_GEN_FAILED =
'team_req/team_req_parent_tree_generation_failed';
/**
* No Postmark Sender Email defined
* (AuthService)
@@ -585,6 +646,13 @@ export const USER_COLL_REORDERING_FAILED =
export const USER_COLL_SAME_NEXT_COLL =
'user_coll/user_collection_and_next_user_collection_are_same' as const;
/**
* The User Collection data is not valid
* (UserCollectionService)
*/
export const USER_COLL_DATA_INVALID =
'user_coll/user_coll_data_invalid' as const;
/**
* The User Collection does not belong to the logged-in user
* (UserCollectionService)
@@ -610,6 +678,19 @@ export const MAILER_SMTP_URL_UNDEFINED = 'mailer/smtp_url_undefined' as const;
export const MAILER_FROM_ADDRESS_UNDEFINED =
'mailer/from_address_undefined' as const;
/**
* MAILER_SMTP_USER environment variable is not defined
* (MailerModule)
*/
export const MAILER_SMTP_USER_UNDEFINED = 'mailer/smtp_user_undefined' as const;
/**
* MAILER_SMTP_PASSWORD environment variable is not defined
* (MailerModule)
*/
export const MAILER_SMTP_PASSWORD_UNDEFINED =
'mailer/smtp_password_undefined' as const;
/**
* SharedRequest invalid request JSON format
* (ShortcodeService)
@@ -630,3 +711,145 @@ export const SHORTCODE_INVALID_PROPERTIES_JSON =
*/
export const SHORTCODE_PROPERTIES_NOT_FOUND =
'shortcode/properties_not_found' as const;
/**
* Infra Config not found
* (InfraConfigService)
*/
export const INFRA_CONFIG_NOT_FOUND = 'infra_config/not_found' as const;
/**
* Infra Config update failed
* (InfraConfigService)
*/
export const INFRA_CONFIG_UPDATE_FAILED = 'infra_config/update_failed' as const;
/**
* Infra Config not listed for onModuleInit creation
* (InfraConfigService)
*/
export const INFRA_CONFIG_NOT_LISTED =
'infra_config/properly_not_listed' as const;
/**
* Infra Config reset failed
* (InfraConfigService)
*/
export const INFRA_CONFIG_RESET_FAILED = 'infra_config/reset_failed' as const;
/**
* Infra Config invalid input for Config variable
* (InfraConfigService)
*/
export const INFRA_CONFIG_INVALID_INPUT = 'infra_config/invalid_input' as const;
/**
* Infra Config service (auth provider/mailer/audit logs) not configured
* (InfraConfigService)
*/
export const INFRA_CONFIG_SERVICE_NOT_CONFIGURED =
'infra_config/service_not_configured' as const;
/**
* Infra Config update/fetch operation not allowed
* (InfraConfigService)
*/
export const INFRA_CONFIG_OPERATION_NOT_ALLOWED =
'infra_config/operation_not_allowed';
/**
* Error message for when the database table does not exist
* (InfraConfigService)
*/
export const DATABASE_TABLE_NOT_EXIST =
'Database migration not found. Please check the documentation for assistance: https://docs.hoppscotch.io/documentation/self-host/community-edition/install-and-build#running-migrations';
/**
* PostHog client is not initialized
* (InfraConfigService)
*/
export const POSTHOG_CLIENT_NOT_INITIALIZED = 'posthog/client_not_initialized';
/**
* Inputs supplied are invalid
*/
export const INVALID_PARAMS = 'invalid_parameters' as const;
/**
* The provided label for the access-token is short (less than 3 characters)
* (AccessTokenService)
*/
export const ACCESS_TOKEN_LABEL_SHORT = 'access_token/label_too_short';
/**
* The provided expiryInDays value is not valid
* (AccessTokenService)
*/
export const ACCESS_TOKEN_EXPIRY_INVALID = 'access_token/expiry_days_invalid';
/**
* The provided PAT ID is invalid
* (AccessTokenService)
*/
export const ACCESS_TOKEN_NOT_FOUND = 'access_token/access_token_not_found';
/**
* AccessTokens is expired
* (AccessTokenService)
*/
export const ACCESS_TOKEN_EXPIRED = 'TOKEN_EXPIRED';
/**
* AccessTokens is invalid
* (AccessTokenService)
*/
export const ACCESS_TOKEN_INVALID = 'TOKEN_INVALID';
/**
* AccessTokens is invalid
* (AccessTokenService)
*/
export const ACCESS_TOKENS_INVALID_DATA_ID = 'INVALID_ID';
/**
* The provided label for the infra-token is short (less than 3 characters)
* (InfraTokenService)
*/
export const INFRA_TOKEN_LABEL_SHORT = 'infra_token/label_too_short';
/**
* The provided expiryInDays value is not valid
* (InfraTokenService)
*/
export const INFRA_TOKEN_EXPIRY_INVALID = 'infra_token/expiry_days_invalid';
/**
* The provided Infra Token ID is invalid
* (InfraTokenService)
*/
export const INFRA_TOKEN_NOT_FOUND = 'infra_token/infra_token_not_found';
/**
* Authorization missing in header (Check 'Authorization' Header)
* (InfraTokenGuard)
*/
export const INFRA_TOKEN_HEADER_MISSING =
'infra_token/authorization_token_missing';
/**
* Infra Token is invalid
* (InfraTokenGuard)
*/
export const INFRA_TOKEN_INVALID_TOKEN = 'infra_token/invalid_token';
/**
* Infra Token is expired
* (InfraTokenGuard)
*/
export const INFRA_TOKEN_EXPIRED = 'infra_token/expired';
/**
* Token creator not found
* (InfraTokenService)
*/
export const INFRA_TOKEN_CREATOR_NOT_FOUND = 'infra_token/creator_not_found';

View File

@@ -28,6 +28,8 @@ import { UserEnvsUserResolver } from './user-environment/user.resolver';
import { UserHistoryUserResolver } from './user-history/user.resolver';
import { UserSettingsUserResolver } from './user-settings/user.resolver';
import { InfraResolver } from './admin/infra.resolver';
import { InfraConfigResolver } from './infra-config/infra-config.resolver';
import { InfraTokenResolver } from './infra-token/infra-token.resolver';
/**
* All the resolvers present in the application.
@@ -58,6 +60,8 @@ const RESOLVERS = [
UserRequestUserCollectionResolver,
UserSettingsResolver,
UserSettingsUserResolver,
InfraConfigResolver,
InfraTokenResolver,
];
/**

View File

@@ -0,0 +1,47 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { DateTime } from 'luxon';
import {
INFRA_TOKEN_EXPIRED,
INFRA_TOKEN_HEADER_MISSING,
INFRA_TOKEN_INVALID_TOKEN,
} from 'src/errors';
@Injectable()
export class InfraTokenGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const authorization = request.headers['authorization'];
if (!authorization)
throw new UnauthorizedException(INFRA_TOKEN_HEADER_MISSING);
if (!authorization.startsWith('Bearer '))
throw new UnauthorizedException(INFRA_TOKEN_INVALID_TOKEN);
const token = authorization.split(' ')[1];
if (!token) throw new UnauthorizedException(INFRA_TOKEN_INVALID_TOKEN);
const infraToken = await this.prisma.infraToken.findUnique({
where: { token },
});
if (infraToken === null)
throw new UnauthorizedException(INFRA_TOKEN_INVALID_TOKEN);
const currentTime = DateTime.now().toISO();
if (currentTime > infraToken.expiresOn?.toISOString()) {
throw new UnauthorizedException(INFRA_TOKEN_EXPIRED);
}
return true;
}
}

View File

@@ -0,0 +1,48 @@
import {
BadRequestException,
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import { Request } from 'express';
import { AccessTokenService } from 'src/access-token/access-token.service';
import * as E from 'fp-ts/Either';
import { DateTime } from 'luxon';
import { ACCESS_TOKEN_EXPIRED, ACCESS_TOKEN_INVALID } from 'src/errors';
import { createCLIErrorResponse } from 'src/access-token/helper';
@Injectable()
export class PATAuthGuard implements CanActivate {
constructor(private accessTokenService: AccessTokenService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new BadRequestException(
createCLIErrorResponse(ACCESS_TOKEN_INVALID),
);
}
const userAccessToken = await this.accessTokenService.getUserPAT(token);
if (E.isLeft(userAccessToken))
throw new BadRequestException(
createCLIErrorResponse(ACCESS_TOKEN_INVALID),
);
request.user = userAccessToken.right.user;
const accessToken = userAccessToken.right;
if (accessToken.expiresOn === null) return true;
const today = DateTime.now().toISO();
if (accessToken.expiresOn.toISOString() > today) return true;
throw new BadRequestException(
createCLIErrorResponse(ACCESS_TOKEN_EXPIRED),
);
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@@ -0,0 +1,24 @@
import { Controller, Get } from '@nestjs/common';
import {
HealthCheck,
HealthCheckService,
PrismaHealthIndicator,
} from '@nestjs/terminus';
import { PrismaService } from 'src/prisma/prisma.service';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private prismaHealth: PrismaHealthIndicator,
private prisma: PrismaService,
) {}
@Get()
@HealthCheck()
check() {
return this.health.check([
async () => this.prismaHealth.pingCheck('database', this.prisma),
]);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { PrismaModule } from 'src/prisma/prisma.module';
import { TerminusModule } from '@nestjs/terminus';
@Module({
imports: [PrismaModule, TerminusModule],
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -0,0 +1,298 @@
import { AuthProvider } from 'src/auth/helper';
import {
AUTH_PROVIDER_NOT_CONFIGURED,
DATABASE_TABLE_NOT_EXIST,
} from 'src/errors';
import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { throwErr } from 'src/utils';
import { randomBytes } from 'crypto';
export enum ServiceStatus {
ENABLE = 'ENABLE',
DISABLE = 'DISABLE',
}
const AuthProviderConfigurations = {
[AuthProvider.GOOGLE]: [
InfraConfigEnum.GOOGLE_CLIENT_ID,
InfraConfigEnum.GOOGLE_CLIENT_SECRET,
InfraConfigEnum.GOOGLE_CALLBACK_URL,
InfraConfigEnum.GOOGLE_SCOPE,
],
[AuthProvider.GITHUB]: [
InfraConfigEnum.GITHUB_CLIENT_ID,
InfraConfigEnum.GITHUB_CLIENT_SECRET,
InfraConfigEnum.GITHUB_CALLBACK_URL,
InfraConfigEnum.GITHUB_SCOPE,
],
[AuthProvider.MICROSOFT]: [
InfraConfigEnum.MICROSOFT_CLIENT_ID,
InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
InfraConfigEnum.MICROSOFT_CALLBACK_URL,
InfraConfigEnum.MICROSOFT_SCOPE,
InfraConfigEnum.MICROSOFT_TENANT,
],
[AuthProvider.EMAIL]: !!process.env.MAILER_USE_CUSTOM_CONFIGS
? [
InfraConfigEnum.MAILER_SMTP_HOST,
InfraConfigEnum.MAILER_SMTP_PORT,
InfraConfigEnum.MAILER_SMTP_SECURE,
InfraConfigEnum.MAILER_SMTP_USER,
InfraConfigEnum.MAILER_SMTP_PASSWORD,
InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED,
InfraConfigEnum.MAILER_ADDRESS_FROM,
]
: [InfraConfigEnum.MAILER_SMTP_URL, InfraConfigEnum.MAILER_ADDRESS_FROM],
};
/**
* Load environment variables from the database and set them in the process
*
* @Description Fetch the 'infra_config' table from the database and return it as an object
* (ConfigModule will set the environment variables in the process)
*/
export async function loadInfraConfiguration() {
try {
const prisma = new PrismaService();
const infraConfigs = await prisma.infraConfig.findMany();
let environmentObject: Record<string, any> = {};
infraConfigs.forEach((infraConfig) => {
environmentObject[infraConfig.name] = infraConfig.value;
});
return { INFRA: environmentObject };
} catch (error) {
// Prisma throw error if 'Can't reach at database server' OR 'Table does not exist'
// Reason for not throwing error is, we want successful build during 'postinstall' and generate dist files
return { INFRA: {} };
}
}
/**
* Read the default values from .env file and return them as an array
* @returns Array of default infra configs
*/
export async function getDefaultInfraConfigs(): Promise<
{ name: InfraConfigEnum; value: string }[]
> {
const prisma = new PrismaService();
// Prepare rows for 'infra_config' table with default values (from .env) for each 'name'
const infraConfigDefaultObjs: { name: InfraConfigEnum; value: string }[] = [
{
name: InfraConfigEnum.MAILER_SMTP_ENABLE,
value: process.env.MAILER_SMTP_ENABLE ?? 'true',
},
{
name: InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS,
value: process.env.MAILER_USE_CUSTOM_CONFIGS ?? 'false',
},
{
name: InfraConfigEnum.MAILER_SMTP_URL,
value: process.env.MAILER_SMTP_URL,
},
{
name: InfraConfigEnum.MAILER_ADDRESS_FROM,
value: process.env.MAILER_ADDRESS_FROM,
},
{
name: InfraConfigEnum.MAILER_SMTP_HOST,
value: process.env.MAILER_SMTP_HOST,
},
{
name: InfraConfigEnum.MAILER_SMTP_PORT,
value: process.env.MAILER_SMTP_PORT,
},
{
name: InfraConfigEnum.MAILER_SMTP_SECURE,
value: process.env.MAILER_SMTP_SECURE,
},
{
name: InfraConfigEnum.MAILER_SMTP_USER,
value: process.env.MAILER_SMTP_USER,
},
{
name: InfraConfigEnum.MAILER_SMTP_PASSWORD,
value: process.env.MAILER_SMTP_PASSWORD,
},
{
name: InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED,
value: process.env.MAILER_TLS_REJECT_UNAUTHORIZED,
},
{
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
value: process.env.GOOGLE_CLIENT_ID,
},
{
name: InfraConfigEnum.GOOGLE_CLIENT_SECRET,
value: process.env.GOOGLE_CLIENT_SECRET,
},
{
name: InfraConfigEnum.GOOGLE_CALLBACK_URL,
value: process.env.GOOGLE_CALLBACK_URL,
},
{
name: InfraConfigEnum.GOOGLE_SCOPE,
value: process.env.GOOGLE_SCOPE,
},
{
name: InfraConfigEnum.GITHUB_CLIENT_ID,
value: process.env.GITHUB_CLIENT_ID,
},
{
name: InfraConfigEnum.GITHUB_CLIENT_SECRET,
value: process.env.GITHUB_CLIENT_SECRET,
},
{
name: InfraConfigEnum.GITHUB_CALLBACK_URL,
value: process.env.GITHUB_CALLBACK_URL,
},
{
name: InfraConfigEnum.GITHUB_SCOPE,
value: process.env.GITHUB_SCOPE,
},
{
name: InfraConfigEnum.MICROSOFT_CLIENT_ID,
value: process.env.MICROSOFT_CLIENT_ID,
},
{
name: InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
value: process.env.MICROSOFT_CLIENT_SECRET,
},
{
name: InfraConfigEnum.MICROSOFT_CALLBACK_URL,
value: process.env.MICROSOFT_CALLBACK_URL,
},
{
name: InfraConfigEnum.MICROSOFT_SCOPE,
value: process.env.MICROSOFT_SCOPE,
},
{
name: InfraConfigEnum.MICROSOFT_TENANT,
value: process.env.MICROSOFT_TENANT,
},
{
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
value: getConfiguredSSOProviders(),
},
{
name: InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
value: false.toString(),
},
{
name: InfraConfigEnum.ANALYTICS_USER_ID,
value: generateAnalyticsUserId(),
},
{
name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
value: (await prisma.infraConfig.count()) === 0 ? 'true' : 'false',
},
];
return infraConfigDefaultObjs;
}
/**
* Get the missing entries in the 'infra_config' table
* @returns Array of InfraConfig
*/
export async function getMissingInfraConfigEntries() {
const prisma = new PrismaService();
const [dbInfraConfigs, infraConfigDefaultObjs] = await Promise.all([
prisma.infraConfig.findMany(),
getDefaultInfraConfigs(),
]);
const missingEntries = infraConfigDefaultObjs.filter(
(config) =>
!dbInfraConfigs.some((dbConfig) => dbConfig.name === config.name),
);
return missingEntries;
}
/**
* Verify if 'infra_config' table is loaded with all entries
* @returns boolean
*/
export async function isInfraConfigTablePopulated(): Promise<boolean> {
const prisma = new PrismaService();
try {
const propsRemainingToInsert = await getMissingInfraConfigEntries();
if (propsRemainingToInsert.length > 0) {
console.log(
'Infra Config table is not populated with all entries. Populating now...',
);
return false;
}
return true;
} catch (error) {
return false;
}
}
/**
* Stop the app after 5 seconds
* (Docker will re-start the app)
*/
export function stopApp() {
console.log('Stopping app in 5 seconds...');
setTimeout(() => {
console.log('Stopping app now...');
process.kill(process.pid, 'SIGTERM');
}, 5000);
}
/**
* Get the configured SSO providers
* @returns Array of configured SSO providers
*/
export function getConfiguredSSOProviders() {
const allowedAuthProviders: string[] =
process.env.VITE_ALLOWED_AUTH_PROVIDERS.split(',');
let configuredAuthProviders: string[] = [];
const addProviderIfConfigured = (provider) => {
const configParameters: string[] = AuthProviderConfigurations[provider];
const isConfigured = configParameters.every((configParameter) => {
return process.env[configParameter];
});
if (isConfigured) configuredAuthProviders.push(provider);
};
allowedAuthProviders.forEach((provider) => addProviderIfConfigured(provider));
if (configuredAuthProviders.length === 0) {
throwErr(AUTH_PROVIDER_NOT_CONFIGURED);
} else if (allowedAuthProviders.length !== configuredAuthProviders.length) {
const unConfiguredAuthProviders = allowedAuthProviders.filter(
(provider) => {
return !configuredAuthProviders.includes(provider);
},
);
console.log(
`${unConfiguredAuthProviders.join(
',',
)} SSO auth provider(s) are not configured properly. Do configure them from Admin Dashboard.`,
);
}
return configuredAuthProviders.join(',');
}
/**
* Generate a hashed valued for analytics
* @returns Generated hashed value
*/
export function generateAnalyticsUserId() {
const hashedUserID = randomBytes(20).toString('hex');
return hashedUserID;
}

View File

@@ -0,0 +1,47 @@
import { Controller, Get, HttpStatus, Put, UseGuards } from '@nestjs/common';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import { InfraConfigService } from './infra-config.service';
import * as E from 'fp-ts/Either';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import { RESTAdminGuard } from 'src/admin/guards/rest-admin.guard';
import { RESTError } from 'src/types/RESTError';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { throwHTTPErr } from 'src/utils';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'site', version: '1' })
export class SiteController {
constructor(private infraConfigService: InfraConfigService) {}
@Get('setup')
@UseGuards(JwtAuthGuard, RESTAdminGuard)
async fetchSetupInfo() {
const status = await this.infraConfigService.get(
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
);
if (E.isLeft(status))
throwHTTPErr(<RESTError>{
message: status.left,
statusCode: HttpStatus.NOT_FOUND,
});
return status.right;
}
@Put('setup')
@UseGuards(JwtAuthGuard, RESTAdminGuard)
async setSetupAsComplete() {
const res = await this.infraConfigService.update(
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
false.toString(),
false,
);
if (E.isLeft(res))
throwHTTPErr(<RESTError>{
message: res.left,
statusCode: HttpStatus.FORBIDDEN,
});
return res.right;
}
}

View File

@@ -0,0 +1,29 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { AuthProvider } from 'src/auth/helper';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { ServiceStatus } from './helper';
@ObjectType()
export class InfraConfig {
@Field({
description: 'Infra Config Name',
})
name: InfraConfigEnum;
@Field({
description: 'Infra Config Value',
})
value: string;
}
registerEnumType(InfraConfigEnum, {
name: 'InfraConfigEnum',
});
registerEnumType(AuthProvider, {
name: 'AuthProvider',
});
registerEnumType(ServiceStatus, {
name: 'ServiceStatus',
});

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { InfraConfigService } from './infra-config.service';
import { PrismaModule } from 'src/prisma/prisma.module';
import { SiteController } from './infra-config.controller';
import { InfraConfigResolver } from './infra-config.resolver';
@Module({
imports: [PrismaModule],
providers: [InfraConfigResolver, InfraConfigService],
exports: [InfraConfigService],
controllers: [SiteController],
})
export class InfraConfigModule {}

View File

@@ -0,0 +1,20 @@
import { UseGuards } from '@nestjs/common';
import { Query, Resolver } from '@nestjs/graphql';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { InfraConfig } from './infra-config.model';
import { InfraConfigService } from './infra-config.service';
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => InfraConfig)
export class InfraConfigResolver {
constructor(private infraConfigService: InfraConfigService) {}
@Query(() => Boolean, {
description: 'Check if the SMTP is enabled or not',
})
@UseGuards(GqlAuthGuard)
isSMTPEnabled() {
return this.infraConfigService.isSMTPEnabled();
}
}

View File

@@ -0,0 +1,223 @@
import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfigService } from './infra-config.service';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import {
INFRA_CONFIG_NOT_FOUND,
INFRA_CONFIG_OPERATION_NOT_ALLOWED,
INFRA_CONFIG_UPDATE_FAILED,
} from 'src/errors';
import { ConfigService } from '@nestjs/config';
import * as helper from './helper';
import { InfraConfig as dbInfraConfig } from '@prisma/client';
import { InfraConfig } from './infra-config.model';
const mockPrisma = mockDeep<PrismaService>();
const mockConfigService = mockDeep<ConfigService>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const infraConfigService = new InfraConfigService(
mockPrisma,
mockConfigService,
);
const INITIALIZED_DATE_CONST = new Date();
const dbInfraConfigs: dbInfraConfig[] = [
{
id: '3',
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
value: 'abcdefghijkl',
active: true,
createdOn: INITIALIZED_DATE_CONST,
updatedOn: INITIALIZED_DATE_CONST,
},
{
id: '4',
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
value: 'google',
active: true,
createdOn: INITIALIZED_DATE_CONST,
updatedOn: INITIALIZED_DATE_CONST,
},
];
const infraConfigs: InfraConfig[] = [
{
name: dbInfraConfigs[0].name as InfraConfigEnum,
value: dbInfraConfigs[0].value,
},
{
name: dbInfraConfigs[1].name as InfraConfigEnum,
value: dbInfraConfigs[1].value,
},
];
beforeEach(() => {
mockReset(mockPrisma);
});
describe('InfraConfigService', () => {
describe('update', () => {
it('should update the infra config without backend server restart', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.update.mockResolvedValueOnce({
id: '',
name,
value,
active: true,
createdOn: new Date(),
updatedOn: new Date(),
});
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
const result = await infraConfigService.update(name, value);
expect(helper.stopApp).not.toHaveBeenCalled();
expect(result).toEqualRight({ name, value });
});
it('should update the infra config with backend server restart', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.update.mockResolvedValueOnce({
id: '',
name,
value,
active: true,
createdOn: new Date(),
updatedOn: new Date(),
});
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
const result = await infraConfigService.update(name, value, true);
expect(helper.stopApp).toHaveBeenCalledTimes(1);
expect(result).toEqualRight({ name, value });
});
it('should update the infra config', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.update.mockResolvedValueOnce({
id: '',
name,
value,
active: true,
createdOn: new Date(),
updatedOn: new Date(),
});
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
const result = await infraConfigService.update(name, value);
expect(result).toEqualRight({ name, value });
});
it('should pass correct params to prisma update', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
await infraConfigService.update(name, value);
expect(mockPrisma.infraConfig.update).toHaveBeenCalledWith({
where: { name },
data: { value },
});
expect(mockPrisma.infraConfig.update).toHaveBeenCalledTimes(1);
});
it('should throw an error if the infra config update failed', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.update.mockRejectedValueOnce('null');
const result = await infraConfigService.update(name, value);
expect(result).toEqualLeft(INFRA_CONFIG_UPDATE_FAILED);
});
});
describe('get', () => {
it('should get the infra config', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.findUniqueOrThrow.mockResolvedValueOnce({
id: '',
name,
value,
active: true,
createdOn: new Date(),
updatedOn: new Date(),
});
const result = await infraConfigService.get(name);
expect(result).toEqualRight({ name, value });
});
it('should pass correct params to prisma findUnique', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
await infraConfigService.get(name);
expect(mockPrisma.infraConfig.findUniqueOrThrow).toHaveBeenCalledWith({
where: { name },
});
expect(mockPrisma.infraConfig.findUniqueOrThrow).toHaveBeenCalledTimes(1);
});
it('should throw an error if the infra config does not exist', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
mockPrisma.infraConfig.findUniqueOrThrow.mockRejectedValueOnce('null');
const result = await infraConfigService.get(name);
expect(result).toEqualLeft(INFRA_CONFIG_NOT_FOUND);
});
});
describe('getMany', () => {
it('should throw error if any disallowed names are provided', async () => {
const disallowedNames = [InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS];
const result = await infraConfigService.getMany(disallowedNames);
expect(result).toEqualLeft(INFRA_CONFIG_OPERATION_NOT_ALLOWED);
});
it('should resolve right with disallowed names if `checkDisallowed` parameter passed', async () => {
const disallowedNames = [InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS];
const dbInfraConfigResponses = dbInfraConfigs.filter((dbConfig) =>
disallowedNames.includes(dbConfig.name as InfraConfigEnum),
);
mockPrisma.infraConfig.findMany.mockResolvedValueOnce(
dbInfraConfigResponses,
);
const result = await infraConfigService.getMany(disallowedNames, false);
expect(result).toEqualRight(
infraConfigs.filter((i) => disallowedNames.includes(i.name)),
);
});
it('should return right with infraConfigs if Prisma query succeeds', async () => {
const allowedNames = [InfraConfigEnum.GOOGLE_CLIENT_ID];
const dbInfraConfigResponses = dbInfraConfigs.filter((dbConfig) =>
allowedNames.includes(dbConfig.name as InfraConfigEnum),
);
mockPrisma.infraConfig.findMany.mockResolvedValueOnce(
dbInfraConfigResponses,
);
const result = await infraConfigService.getMany(allowedNames);
expect(result).toEqualRight(
infraConfigs.filter((i) => allowedNames.includes(i.name)),
);
});
});
});

View File

@@ -0,0 +1,528 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InfraConfig } from './infra-config.model';
import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfig as DBInfraConfig } from '@prisma/client';
import * as E from 'fp-ts/Either';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import {
AUTH_PROVIDER_NOT_SPECIFIED,
DATABASE_TABLE_NOT_EXIST,
INFRA_CONFIG_INVALID_INPUT,
INFRA_CONFIG_NOT_FOUND,
INFRA_CONFIG_RESET_FAILED,
INFRA_CONFIG_UPDATE_FAILED,
INFRA_CONFIG_SERVICE_NOT_CONFIGURED,
INFRA_CONFIG_OPERATION_NOT_ALLOWED,
} from 'src/errors';
import {
throwErr,
validateSMTPEmail,
validateSMTPUrl,
validateUrl,
} from 'src/utils';
import { ConfigService } from '@nestjs/config';
import {
ServiceStatus,
getDefaultInfraConfigs,
getMissingInfraConfigEntries,
stopApp,
} from './helper';
import { EnableAndDisableSSOArgs, InfraConfigArgs } from './input-args';
import { AuthProvider } from 'src/auth/helper';
@Injectable()
export class InfraConfigService implements OnModuleInit {
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
) {}
// Following fields are not updatable by `infraConfigs` Mutation. Use dedicated mutations for these fields instead.
EXCLUDE_FROM_UPDATE_CONFIGS = [
InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
InfraConfigEnum.ANALYTICS_USER_ID,
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
InfraConfigEnum.MAILER_SMTP_ENABLE,
];
// Following fields can not be fetched by `infraConfigs` Query. Use dedicated queries for these fields instead.
EXCLUDE_FROM_FETCH_CONFIGS = [
InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
InfraConfigEnum.ANALYTICS_USER_ID,
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
];
async onModuleInit() {
await this.initializeInfraConfigTable();
}
/**
* Initialize the 'infra_config' table with values from .env
* @description This function create rows 'infra_config' in very first time (only once)
*/
async initializeInfraConfigTable() {
try {
const propsToInsert = await getMissingInfraConfigEntries();
if (propsToInsert.length > 0) {
await this.prisma.infraConfig.createMany({ data: propsToInsert });
stopApp();
}
} catch (error) {
if (error.code === 'P1001') {
// Prisma error code for 'Can't reach at database server'
// We're not throwing error here because we want to allow the app to run 'pnpm install'
} else if (error.code === 'P2021') {
// Prisma error code for 'Table does not exist'
throwErr(DATABASE_TABLE_NOT_EXIST);
} else {
throwErr(error);
}
}
}
/**
* Typecast a database InfraConfig to a InfraConfig model
* @param dbInfraConfig database InfraConfig
* @returns InfraConfig model
*/
cast(dbInfraConfig: DBInfraConfig) {
return <InfraConfig>{
name: dbInfraConfig.name,
value: dbInfraConfig.value ?? '',
};
}
/**
* Get all the InfraConfigs as map
* @returns InfraConfig map
*/
async getInfraConfigsMap() {
const infraConfigs = await this.prisma.infraConfig.findMany();
const infraConfigMap: Record<string, string> = {};
infraConfigs.forEach((config) => {
infraConfigMap[config.name] = config.value;
});
return infraConfigMap;
}
/**
* Update InfraConfig by name
* @param name Name of the InfraConfig
* @param value Value of the InfraConfig
* @param restartEnabled If true, restart the app after updating the InfraConfig
* @returns InfraConfig model
*/
async update(name: InfraConfigEnum, value: string, restartEnabled = false) {
const isValidate = this.validateEnvValues([{ name, value }]);
if (E.isLeft(isValidate)) return E.left(isValidate.left);
try {
const infraConfig = await this.prisma.infraConfig.update({
where: { name },
data: { value },
});
if (restartEnabled) stopApp();
return E.right(this.cast(infraConfig));
} catch (e) {
return E.left(INFRA_CONFIG_UPDATE_FAILED);
}
}
/**
* Update InfraConfigs by name
* @param infraConfigs InfraConfigs to update
* @returns InfraConfig model
*/
async updateMany(infraConfigs: InfraConfigArgs[]) {
for (let i = 0; i < infraConfigs.length; i++) {
if (this.EXCLUDE_FROM_UPDATE_CONFIGS.includes(infraConfigs[i].name))
return E.left(INFRA_CONFIG_OPERATION_NOT_ALLOWED);
}
const isValidate = this.validateEnvValues(infraConfigs);
if (E.isLeft(isValidate)) return E.left(isValidate.left);
try {
await this.prisma.$transaction(async (tx) => {
for (let i = 0; i < infraConfigs.length; i++) {
await tx.infraConfig.update({
where: { name: infraConfigs[i].name },
data: { value: infraConfigs[i].value },
});
}
});
stopApp();
return E.right(infraConfigs);
} catch (e) {
return E.left(INFRA_CONFIG_UPDATE_FAILED);
}
}
/**
* Check if the service is configured or not
* @param service Service can be Auth Provider, Mailer, Audit Log etc.
* @param configMap Map of all the infra configs
* @returns Either true or false
*/
isServiceConfigured(
service: AuthProvider,
configMap: Record<string, string>,
) {
switch (service) {
case AuthProvider.GOOGLE:
return (
configMap.GOOGLE_CLIENT_ID &&
configMap.GOOGLE_CLIENT_SECRET &&
configMap.GOOGLE_CALLBACK_URL &&
configMap.GOOGLE_SCOPE
);
case AuthProvider.GITHUB:
return (
configMap.GITHUB_CLIENT_ID &&
configMap.GITHUB_CLIENT_SECRET &&
configMap.GITHUB_CALLBACK_URL &&
configMap.GITHUB_SCOPE
);
case AuthProvider.MICROSOFT:
return (
configMap.MICROSOFT_CLIENT_ID &&
configMap.MICROSOFT_CLIENT_SECRET &&
configMap.MICROSOFT_CALLBACK_URL &&
configMap.MICROSOFT_SCOPE &&
configMap.MICROSOFT_TENANT
);
case AuthProvider.EMAIL:
if (configMap.MAILER_SMTP_ENABLE !== 'true') return false;
if (configMap.MAILER_USE_CUSTOM_CONFIGS === 'true') {
return (
configMap.MAILER_SMTP_HOST &&
configMap.MAILER_SMTP_PORT &&
configMap.MAILER_SMTP_SECURE &&
configMap.MAILER_SMTP_USER &&
configMap.MAILER_SMTP_PASSWORD &&
configMap.MAILER_TLS_REJECT_UNAUTHORIZED &&
configMap.MAILER_ADDRESS_FROM
);
} else {
return configMap.MAILER_SMTP_URL && configMap.MAILER_ADDRESS_FROM;
}
default:
return false;
}
}
/**
* Enable or Disable Analytics Collection
*
* @param status Status to enable or disable
* @returns Boolean of status of analytics collection
*/
async toggleAnalyticsCollection(status: ServiceStatus) {
const isUpdated = await this.update(
InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
status === ServiceStatus.ENABLE ? 'true' : 'false',
);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(isUpdated.right.value === 'true');
}
/**
* Enable or Disable SMTP
* @param status Status to enable or disable
* @returns Either true or an error
*/
async enableAndDisableSMTP(status: ServiceStatus) {
const isUpdated = await this.toggleServiceStatus(
InfraConfigEnum.MAILER_SMTP_ENABLE,
status,
true,
);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
if (status === ServiceStatus.DISABLE) {
this.enableAndDisableSSO([{ provider: AuthProvider.EMAIL, status }]);
}
return E.right(true);
}
/**
* Enable or Disable Service (i.e. ALLOW_AUDIT_LOGS, ALLOW_ANALYTICS_COLLECTION, ALLOW_DOMAIN_WHITELISTING, SITE_PROTECTION)
* @param configName Name of the InfraConfigEnum
* @param status Status to enable or disable
* @param restartEnabled If true, restart the app after updating the InfraConfig
* @returns Either true or an error
*/
async toggleServiceStatus(
configName: InfraConfigEnum,
status: ServiceStatus,
restartEnabled = false,
) {
const isUpdated = await this.update(
configName,
status === ServiceStatus.ENABLE ? 'true' : 'false',
restartEnabled,
);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(true);
}
/**
* Enable or Disable SSO for login/signup
* @param provider Auth Provider to enable or disable
* @param status Status to enable or disable
* @returns Either true or an error
*/
async enableAndDisableSSO(providerInfo: EnableAndDisableSSOArgs[]) {
const allowedAuthProviders = this.configService
.get<string>('INFRA.VITE_ALLOWED_AUTH_PROVIDERS')
.split(',');
let updatedAuthProviders = allowedAuthProviders;
const infraConfigMap = await this.getInfraConfigsMap();
providerInfo.forEach(({ provider, status }) => {
if (status === ServiceStatus.ENABLE) {
const isConfigured = this.isServiceConfigured(provider, infraConfigMap);
if (!isConfigured) {
throwErr(INFRA_CONFIG_SERVICE_NOT_CONFIGURED);
}
updatedAuthProviders.push(provider);
} else if (status === ServiceStatus.DISABLE) {
updatedAuthProviders = updatedAuthProviders.filter(
(p) => p !== provider,
);
}
});
updatedAuthProviders = [...new Set(updatedAuthProviders)];
if (updatedAuthProviders.length === 0) {
return E.left(AUTH_PROVIDER_NOT_SPECIFIED);
}
const isUpdated = await this.update(
InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
updatedAuthProviders.join(','),
true,
);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(true);
}
/**
* Get InfraConfig by name
* @param name Name of the InfraConfig
* @returns InfraConfig model
*/
async get(name: InfraConfigEnum) {
try {
const infraConfig = await this.prisma.infraConfig.findUniqueOrThrow({
where: { name },
});
return E.right(this.cast(infraConfig));
} catch (e) {
return E.left(INFRA_CONFIG_NOT_FOUND);
}
}
/**
* Get InfraConfigs by names
* @param names Names of the InfraConfigs
* @param checkDisallowedKeys If true, check if the names are allowed to fetch by client
* @returns InfraConfig model
*/
async getMany(names: InfraConfigEnum[], checkDisallowedKeys: boolean = true) {
if (checkDisallowedKeys) {
// Check if the names are allowed to fetch by client
for (let i = 0; i < names.length; i++) {
if (this.EXCLUDE_FROM_FETCH_CONFIGS.includes(names[i]))
return E.left(INFRA_CONFIG_OPERATION_NOT_ALLOWED);
}
}
try {
const infraConfigs = await this.prisma.infraConfig.findMany({
where: { name: { in: names } },
});
return E.right(infraConfigs.map((p) => this.cast(p)));
} catch (e) {
return E.left(INFRA_CONFIG_NOT_FOUND);
}
}
/**
* Get allowed auth providers for login/signup
* @returns string[]
*/
getAllowedAuthProviders() {
return this.configService
.get<string>('INFRA.VITE_ALLOWED_AUTH_PROVIDERS')
.split(',');
}
/**
* Check if SMTP is enabled or not
* @returns boolean
*/
isSMTPEnabled() {
return (
this.configService.get<string>('INFRA.MAILER_SMTP_ENABLE') === 'true'
);
}
/**
* Reset all the InfraConfigs to their default values (from .env)
*/
async reset() {
// These are all the infra-configs that should not be reset
const RESET_EXCLUSION_LIST = [
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
InfraConfigEnum.ANALYTICS_USER_ID,
InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
];
try {
const infraConfigDefaultObjs = await getDefaultInfraConfigs();
const updatedInfraConfigDefaultObjs = infraConfigDefaultObjs.filter(
(p) => RESET_EXCLUSION_LIST.includes(p.name) === false,
);
await this.prisma.infraConfig.deleteMany({
where: {
name: {
in: updatedInfraConfigDefaultObjs.map((p) => p.name),
},
},
});
await this.prisma.infraConfig.createMany({
data: updatedInfraConfigDefaultObjs,
});
stopApp();
return E.right(true);
} catch (e) {
return E.left(INFRA_CONFIG_RESET_FAILED);
}
}
/**
* Validate the values of the InfraConfigs
*/
validateEnvValues(
infraConfigs: {
name: InfraConfigEnum;
value: string;
}[],
) {
for (let i = 0; i < infraConfigs.length; i++) {
switch (infraConfigs[i].name) {
case InfraConfigEnum.MAILER_SMTP_ENABLE:
if (
infraConfigs[i].value !== 'true' &&
infraConfigs[i].value !== 'false'
)
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS:
if (
infraConfigs[i].value !== 'true' &&
infraConfigs[i].value !== 'false'
)
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MAILER_SMTP_URL:
const isValidUrl = validateSMTPUrl(infraConfigs[i].value);
if (!isValidUrl) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MAILER_ADDRESS_FROM:
const isValidEmail = validateSMTPEmail(infraConfigs[i].value);
if (!isValidEmail) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MAILER_SMTP_HOST:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MAILER_SMTP_PORT:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MAILER_SMTP_SECURE:
if (
infraConfigs[i].value !== 'true' &&
infraConfigs[i].value !== 'false'
)
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MAILER_SMTP_USER:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MAILER_SMTP_PASSWORD:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED:
if (
infraConfigs[i].value !== 'true' &&
infraConfigs[i].value !== 'false'
)
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GOOGLE_CLIENT_ID:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GOOGLE_CLIENT_SECRET:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GOOGLE_CALLBACK_URL:
if (!validateUrl(infraConfigs[i].value))
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GOOGLE_SCOPE:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GITHUB_CLIENT_ID:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GITHUB_CLIENT_SECRET:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GITHUB_CALLBACK_URL:
if (!validateUrl(infraConfigs[i].value))
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GITHUB_SCOPE:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_CLIENT_ID:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_CLIENT_SECRET:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_CALLBACK_URL:
if (!validateUrl(infraConfigs[i].value))
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_SCOPE:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_TENANT:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
default:
break;
}
}
return E.right(true);
}
}

View File

@@ -0,0 +1,30 @@
import { Field, InputType } from '@nestjs/graphql';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { ServiceStatus } from './helper';
import { AuthProvider } from 'src/auth/helper';
@InputType()
export class InfraConfigArgs {
@Field(() => InfraConfigEnum, {
description: 'Infra Config Name',
})
name: InfraConfigEnum;
@Field({
description: 'Infra Config Value',
})
value: string;
}
@InputType()
export class EnableAndDisableSSOArgs {
@Field(() => AuthProvider, {
description: 'Auth Provider',
})
provider: AuthProvider;
@Field(() => ServiceStatus, {
description: 'Auth Provider Status',
})
status: ServiceStatus;
}

View File

@@ -0,0 +1,248 @@
import {
Body,
Controller,
Delete,
Get,
HttpStatus,
Param,
Patch,
Post,
Query,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { AdminService } from 'src/admin/admin.service';
import { InfraTokenGuard } from 'src/guards/infra-token.guard';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import {
DeleteUserInvitationRequest,
DeleteUserInvitationResponse,
ExceptionResponse,
GetUserInvitationResponse,
GetUsersRequestQuery,
GetUserResponse,
UpdateUserRequest,
UpdateUserAdminStatusRequest,
UpdateUserAdminStatusResponse,
CreateUserInvitationRequest,
CreateUserInvitationResponse,
} from './request-response.dto';
import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import {
ApiBadRequestResponse,
ApiCreatedResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { throwHTTPErr } from 'src/utils';
import { UserService } from 'src/user/user.service';
import {
INFRA_TOKEN_CREATOR_NOT_FOUND,
USER_NOT_FOUND,
USERS_NOT_FOUND,
} from 'src/errors';
import { InfraTokenService } from './infra-token.service';
import { InfraTokenInterceptor } from 'src/interceptors/infra-token.interceptor';
import { BearerToken } from 'src/decorators/bearer-token.decorator';
@ApiTags('User Management API')
@ApiSecurity('infra-token')
@UseGuards(ThrottlerBehindProxyGuard, InfraTokenGuard)
@UseInterceptors(InfraTokenInterceptor)
@Controller({ path: 'infra', version: '1' })
export class InfraTokensController {
constructor(
private readonly infraTokenService: InfraTokenService,
private readonly adminService: AdminService,
private readonly userService: UserService,
) {}
@Post('user-invitations')
@ApiCreatedResponse({
description: 'Create a user invitation',
type: CreateUserInvitationResponse,
})
@ApiBadRequestResponse({ type: ExceptionResponse })
@ApiNotFoundResponse({ type: ExceptionResponse })
async createUserInvitation(
@BearerToken() token: string,
@Body() dto: CreateUserInvitationRequest,
) {
const createdInvitations =
await this.infraTokenService.createUserInvitation(token, dto);
if (E.isLeft(createdInvitations)) {
const statusCode =
(createdInvitations.left as string) === INFRA_TOKEN_CREATOR_NOT_FOUND
? HttpStatus.NOT_FOUND
: HttpStatus.BAD_REQUEST;
throwHTTPErr({ message: createdInvitations.left, statusCode });
}
return plainToInstance(
CreateUserInvitationResponse,
{ invitationLink: process.env.VITE_BASE_URL },
{
excludeExtraneousValues: true,
enableImplicitConversion: true,
},
);
}
@Get('user-invitations')
@ApiOkResponse({
description: 'Get pending user invitations',
type: [GetUserInvitationResponse],
})
async getPendingUserInvitation(
@Query() paginationQuery: OffsetPaginationArgs,
) {
const pendingInvitedUsers = await this.adminService.fetchInvitedUsers(
paginationQuery,
);
return plainToInstance(GetUserInvitationResponse, pendingInvitedUsers, {
excludeExtraneousValues: true,
enableImplicitConversion: true,
});
}
@Delete('user-invitations')
@ApiOkResponse({
description: 'Delete a pending user invitation',
type: DeleteUserInvitationResponse,
})
@ApiBadRequestResponse({ type: ExceptionResponse })
async deleteUserInvitation(@Body() dto: DeleteUserInvitationRequest) {
const isDeleted = await this.adminService.revokeUserInvitations(
dto.inviteeEmails,
);
if (E.isLeft(isDeleted)) {
throwHTTPErr({
message: isDeleted.left,
statusCode: HttpStatus.BAD_REQUEST,
});
}
return plainToInstance(
DeleteUserInvitationResponse,
{ message: isDeleted.right },
{
excludeExtraneousValues: true,
enableImplicitConversion: true,
},
);
}
@Get('users')
@ApiOkResponse({
description: 'Get users list',
type: [GetUserResponse],
})
async getUsers(@Query() query: GetUsersRequestQuery) {
const users = await this.userService.fetchAllUsersV2(query.searchString, {
take: query.take,
skip: query.skip,
});
return plainToInstance(GetUserResponse, users, {
excludeExtraneousValues: true,
enableImplicitConversion: true,
});
}
@Get('users/:uid')
@ApiOkResponse({
description: 'Get user details',
type: GetUserResponse,
})
@ApiNotFoundResponse({ type: ExceptionResponse })
async getUser(@Param('uid') uid: string) {
const user = await this.userService.findUserById(uid);
if (O.isNone(user)) {
throwHTTPErr({
message: USER_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,
});
}
return plainToInstance(GetUserResponse, user.value, {
excludeExtraneousValues: true,
enableImplicitConversion: true,
});
}
@Patch('users/:uid')
@ApiOkResponse({
description: 'Update user display name',
type: GetUserResponse,
})
@ApiBadRequestResponse({ type: ExceptionResponse })
@ApiNotFoundResponse({ type: ExceptionResponse })
async updateUser(@Param('uid') uid: string, @Body() body: UpdateUserRequest) {
const updatedUser = await this.userService.updateUserDisplayName(
uid,
body.displayName,
);
if (E.isLeft(updatedUser)) {
const statusCode =
(updatedUser.left as string) === USER_NOT_FOUND
? HttpStatus.NOT_FOUND
: HttpStatus.BAD_REQUEST;
throwHTTPErr({ message: updatedUser.left, statusCode });
}
return plainToInstance(GetUserResponse, updatedUser.right, {
excludeExtraneousValues: true,
enableImplicitConversion: true,
});
}
@Patch('users/:uid/admin-status')
@ApiOkResponse({
description: 'Update user admin status',
type: UpdateUserAdminStatusResponse,
})
@ApiBadRequestResponse({ type: ExceptionResponse })
@ApiNotFoundResponse({ type: ExceptionResponse })
async updateUserAdminStatus(
@Param('uid') uid: string,
@Body() body: UpdateUserAdminStatusRequest,
) {
let updatedUser;
if (body.isAdmin) {
updatedUser = await this.adminService.makeUsersAdmin([uid]);
} else {
updatedUser = await this.adminService.demoteUsersByAdmin([uid]);
}
if (E.isLeft(updatedUser)) {
const statusCode =
(updatedUser.left as string) === USERS_NOT_FOUND
? HttpStatus.NOT_FOUND
: HttpStatus.BAD_REQUEST;
throwHTTPErr({ message: updatedUser.left as string, statusCode });
}
return plainToInstance(
UpdateUserAdminStatusResponse,
{ message: updatedUser.right },
{
excludeExtraneousValues: true,
enableImplicitConversion: true,
},
);
}
}

View File

@@ -0,0 +1,43 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class InfraToken {
@Field(() => ID, {
description: 'ID of the infra token',
})
id: string;
@Field(() => String, {
description: 'Label of the infra token',
})
label: string;
@Field(() => Date, {
description: 'Date when the infra token was created',
})
createdOn: Date;
@Field(() => Date, {
description: 'Date when the infra token expires',
nullable: true,
})
expiresOn: Date;
@Field(() => Date, {
description: 'Date when the infra token was last used',
})
lastUsedOn: Date;
}
@ObjectType()
export class CreateInfraTokenResponse {
@Field(() => String, {
description: 'The infra token',
})
token: string;
@Field(() => InfraToken, {
description: 'Infra token info',
})
info: InfraToken;
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/prisma/prisma.module';
import { InfraTokenResolver } from './infra-token.resolver';
import { InfraTokenService } from './infra-token.service';
import { InfraTokensController } from './infra-token.controller';
import { AdminModule } from 'src/admin/admin.module';
import { UserModule } from 'src/user/user.module';
@Module({
imports: [PrismaModule, AdminModule, UserModule],
controllers: [InfraTokensController],
providers: [InfraTokenResolver, InfraTokenService],
})
export class InfraTokenModule {}

View File

@@ -0,0 +1,68 @@
import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
import { CreateInfraTokenResponse, InfraToken } from './infra-token.model';
import { UseGuards } from '@nestjs/common';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { InfraTokenService } from './infra-token.service';
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
import { GqlAdminGuard } from 'src/admin/guards/gql-admin.guard';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import { GqlAdmin } from 'src/admin/decorators/gql-admin.decorator';
import { Admin } from 'src/admin/admin.model';
import * as E from 'fp-ts/Either';
import { throwErr } from 'src/utils';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => InfraToken)
export class InfraTokenResolver {
constructor(private readonly infraTokenService: InfraTokenService) {}
/* Query */
@Query(() => [InfraToken], {
description: 'Get list of infra tokens',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
infraTokens(@Args() args: OffsetPaginationArgs) {
return this.infraTokenService.getAll(args.take, args.skip);
}
/* Mutations */
@Mutation(() => CreateInfraTokenResponse, {
description: 'Create a new infra token',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async createInfraToken(
@GqlAdmin() admin: Admin,
@Args({ name: 'label', description: 'Label of the token' }) label: string,
@Args({
name: 'expiryInDays',
description: 'Number of days the token is valid for',
nullable: true,
})
expiryInDays: number,
) {
const infraToken = await this.infraTokenService.create(
label,
expiryInDays,
admin,
);
if (E.isLeft(infraToken)) throwErr(infraToken.left);
return infraToken.right;
}
@Mutation(() => Boolean, {
description: 'Revoke an infra token',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async revokeInfraToken(
@Args({ name: 'id', type: () => ID, description: 'ID of the infra token' })
id: string,
) {
const res = await this.infraTokenService.revoke(id);
if (E.isLeft(res)) throwErr(res.left);
return res.right;
}
}

View File

@@ -0,0 +1,160 @@
import { Injectable } from '@nestjs/common';
import { InfraToken as dbInfraToken } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
import { CreateInfraTokenResponse, InfraToken } from './infra-token.model';
import { calculateExpirationDate, isValidLength } from 'src/utils';
import { Admin } from 'src/admin/admin.model';
import {
INFRA_TOKEN_CREATOR_NOT_FOUND,
INFRA_TOKEN_EXPIRY_INVALID,
INFRA_TOKEN_LABEL_SHORT,
INFRA_TOKEN_NOT_FOUND,
} from 'src/errors';
import * as E from 'fp-ts/Either';
import { CreateUserInvitationRequest } from './request-response.dto';
import { AdminService } from 'src/admin/admin.service';
@Injectable()
export class InfraTokenService {
constructor(
private readonly prisma: PrismaService,
private readonly adminService: AdminService,
) {}
TITLE_LENGTH = 3;
VALID_TOKEN_DURATIONS = [7, 30, 60, 90];
/**
* Validate the expiration date of the token
*
* @param expiresOn Number of days the token is valid for
* @returns Boolean indicating if the expiration date is valid
*/
private validateExpirationDate(expiresOn: null | number) {
if (expiresOn === null || this.VALID_TOKEN_DURATIONS.includes(expiresOn))
return true;
return false;
}
/**
* Typecast a database InfraToken to a InfraToken model
* @param dbInfraToken database InfraToken
* @returns InfraToken model
*/
private cast(dbInfraToken: dbInfraToken): InfraToken {
return {
id: dbInfraToken.id,
label: dbInfraToken.label,
createdOn: dbInfraToken.createdOn,
expiresOn: dbInfraToken.expiresOn,
lastUsedOn: dbInfraToken.updatedOn,
};
}
/**
* Fetch all infra tokens with pagination
* @param take take for pagination
* @param skip skip for pagination
* @returns List of InfraToken models
*/
async getAll(take = 10, skip = 0) {
const infraTokens = await this.prisma.infraToken.findMany({
take,
skip,
orderBy: { createdOn: 'desc' },
});
return infraTokens.map((token) => this.cast(token));
}
/**
* Create a new infra token
* @param label label of the token
* @param expiryInDays expiry duration of the token
* @param admin admin who created the token
* @returns Either of error message or CreateInfraTokenResponse
*/
async create(label: string, expiryInDays: number, admin: Admin) {
if (!isValidLength(label, this.TITLE_LENGTH)) {
return E.left(INFRA_TOKEN_LABEL_SHORT);
}
if (!this.validateExpirationDate(expiryInDays ?? null)) {
return E.left(INFRA_TOKEN_EXPIRY_INVALID);
}
const createdInfraToken = await this.prisma.infraToken.create({
data: {
creatorUid: admin.uid,
label,
expiresOn: calculateExpirationDate(expiryInDays ?? null) ?? undefined,
},
});
const res: CreateInfraTokenResponse = {
token: createdInfraToken.token,
info: this.cast(createdInfraToken),
};
return E.right(res);
}
/**
* Revoke an infra token
* @param id ID of the infra token
* @returns Either of error or true
*/
async revoke(id: string) {
try {
await this.prisma.infraToken.delete({
where: { id },
});
} catch (error) {
return E.left(INFRA_TOKEN_NOT_FOUND);
}
return E.right(true);
}
/**
* Update the last used on of an infra token
* @param token token to update
* @returns Either of error or InfraToken
*/
async updateLastUsedOn(token: string) {
try {
const infraToken = await this.prisma.infraToken.update({
where: { token },
data: { updatedOn: new Date() },
});
return E.right(this.cast(infraToken));
} catch (error) {
return E.left(INFRA_TOKEN_NOT_FOUND);
}
}
/**
* Create a user invitation using an infra token
* @param token token used to create the invitation
* @param dto CreateUserInvitationRequest
* @returns Either of error or InvitedUser
*/
async createUserInvitation(token: string, dto: CreateUserInvitationRequest) {
const infraToken = await this.prisma.infraToken.findUnique({
where: { token },
});
const tokenCreator = await this.prisma.user.findUnique({
where: { uid: infraToken.creatorUid },
});
if (!tokenCreator) return E.left(INFRA_TOKEN_CREATOR_NOT_FOUND);
const invitedUser = await this.adminService.inviteUserToSignInViaEmail(
tokenCreator.uid,
tokenCreator.email,
dto.inviteeEmail,
);
if (E.isLeft(invitedUser)) return E.left(invitedUser.left);
return E.right(invitedUser);
}
}

View File

@@ -0,0 +1,115 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Expose, Transform, Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
MinLength,
} from 'class-validator';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
// POST v1/infra/user-invitations
export class CreateUserInvitationRequest {
@Type(() => String)
@IsNotEmpty()
@ApiProperty()
inviteeEmail: string;
}
export class CreateUserInvitationResponse {
@ApiProperty()
@Expose()
invitationLink: string;
}
// GET v1/infra/user-invitations
export class GetUserInvitationResponse {
@ApiProperty()
@Expose()
inviteeEmail: string;
@ApiProperty()
@Expose()
invitedOn: Date;
}
// DELETE v1/infra/user-invitations
export class DeleteUserInvitationRequest {
@IsArray()
@ArrayMinSize(1)
@Type(() => String)
@IsNotEmpty()
@ApiProperty()
inviteeEmails: string[];
}
export class DeleteUserInvitationResponse {
@ApiProperty()
@Expose()
message: string;
}
// POST v1/infra/users
export class GetUsersRequestQuery extends OffsetPaginationArgs {
@IsOptional()
@IsString()
@MinLength(1)
@ApiPropertyOptional()
searchString: string;
}
export class GetUserResponse {
@ApiProperty()
@Expose()
uid: string;
@ApiProperty()
@Expose()
displayName: string;
@ApiProperty()
@Expose()
email: string;
@ApiProperty()
@Expose()
photoURL: string;
@ApiProperty()
@Expose()
isAdmin: boolean;
}
// PATCH v1/infra/users/:uid
export class UpdateUserRequest {
@IsOptional()
@IsString()
@MinLength(1)
@ApiPropertyOptional()
displayName: string;
}
// PATCH v1/infra/users/:uid/admin-status
export class UpdateUserAdminStatusRequest {
@IsBoolean()
@IsNotEmpty()
@ApiProperty()
isAdmin: boolean;
}
export class UpdateUserAdminStatusResponse {
@ApiProperty()
@Expose()
message: string;
}
// Used for Swagger doc only, in codebase throwHTTPErr function is used to throw errors
export class ExceptionResponse {
@ApiProperty()
@Expose()
message: string;
@ApiProperty()
@Expose()
statusCode: number;
}

View File

@@ -0,0 +1,36 @@
import {
BadRequestException,
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { AccessTokenService } from 'src/access-token/access-token.service';
import * as E from 'fp-ts/Either';
import { ACCESS_TOKEN_NOT_FOUND } from 'src/errors';
@Injectable()
export class AccessTokenInterceptor implements NestInterceptor {
constructor(private readonly accessTokenService: AccessTokenService) {}
intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
throw new BadRequestException(ACCESS_TOKEN_NOT_FOUND);
}
return handler.handle().pipe(
map(async (data) => {
const userAccessToken =
await this.accessTokenService.updateLastUsedForPAT(token);
if (E.isLeft(userAccessToken))
throw new BadRequestException(userAccessToken.left);
return data;
}),
);
}
}

View File

@@ -0,0 +1,30 @@
import {
BadRequestException,
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { INFRA_TOKEN_NOT_FOUND } from 'src/errors';
import { InfraTokenService } from 'src/infra-token/infra-token.service';
@Injectable()
export class InfraTokenInterceptor implements NestInterceptor {
constructor(private readonly infraTokenService: InfraTokenService) {}
intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new BadRequestException(INFRA_TOKEN_NOT_FOUND);
}
const token = authHeader.split(' ')[1];
this.infraTokenService.updateLastUsedOn(token);
return handler.handle();
}
}

View File

@@ -0,0 +1,65 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { AuthUser } from 'src/types/AuthUser';
import { UserService } from 'src/user/user.service';
@Injectable()
export class UserLastActiveOnInterceptor implements NestInterceptor {
constructor(private userService: UserService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
if (context.getType() === 'http') {
return this.restHandler(context, next);
} else if (context.getType<GqlContextType>() === 'graphql') {
return this.graphqlHandler(context, next);
}
}
restHandler(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const user: AuthUser = request.user;
return next.handle().pipe(
tap(() => {
if (user && typeof user === 'object') {
this.userService.updateUserLastActiveOn(user.uid);
}
}),
catchError((error) => {
if (user && typeof user === 'object') {
this.userService.updateUserLastActiveOn(user.uid);
}
return throwError(() => error);
}),
);
}
graphqlHandler(
context: ExecutionContext,
next: CallHandler,
): Observable<any> {
const contextObject = GqlExecutionContext.create(context).getContext();
const user: AuthUser = contextObject?.req?.user;
return next.handle().pipe(
tap(() => {
if (user && typeof user === 'object') {
this.userService.updateUserLastActiveOn(user.uid);
}
}),
catchError((error) => {
if (user && typeof user === 'object') {
this.userService.updateUserLastActiveOn(user.uid);
}
return throwError(() => error);
}),
);
}
}

View File

@@ -0,0 +1,25 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { AuthUser } from 'src/types/AuthUser';
import { UserService } from 'src/user/user.service';
@Injectable()
export class UserLastLoginInterceptor implements NestInterceptor {
constructor(private userService: UserService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const user: AuthUser = context.switchToHttp().getRequest().user;
return next.handle().pipe(
tap(() => {
this.userService.updateUserLastLoggedOn(user.uid);
}),
);
}
}

View File

@@ -0,0 +1,59 @@
import { TransportType } from '@nestjs-modules/mailer/dist/interfaces/mailer-options.interface';
import {
MAILER_SMTP_PASSWORD_UNDEFINED,
MAILER_SMTP_URL_UNDEFINED,
MAILER_SMTP_USER_UNDEFINED,
} from 'src/errors';
import { throwErr } from 'src/utils';
function isSMTPCustomConfigsEnabled(value) {
return value === 'true';
}
export function getMailerAddressFrom(env, config): string {
return (
env.INFRA.MAILER_ADDRESS_FROM ??
config.get('MAILER_ADDRESS_FROM') ??
throwErr(MAILER_SMTP_URL_UNDEFINED)
);
}
export function getTransportOption(env, config): TransportType {
const useCustomConfigs = isSMTPCustomConfigsEnabled(
env.INFRA.MAILER_USE_CUSTOM_CONFIGS ??
config.get('MAILER_USE_CUSTOM_CONFIGS'),
);
if (!useCustomConfigs) {
console.log('Using simple mailer configuration');
return (
env.INFRA.MAILER_SMTP_URL ??
config.get('MAILER_SMTP_URL') ??
throwErr(MAILER_SMTP_URL_UNDEFINED)
);
} else {
console.log('Using advanced mailer configuration');
return {
host: env.INFRA.MAILER_SMTP_HOST ?? config.get('MAILER_SMTP_HOST'),
port: +env.INFRA.MAILER_SMTP_PORT ?? +config.get('MAILER_SMTP_PORT'),
secure:
(env.INFRA.MAILER_SMTP_SECURE ?? config.get('MAILER_SMTP_SECURE')) ===
'true',
auth: {
user:
env.INFRA.MAILER_SMTP_USER ??
config.get('MAILER_SMTP_USER') ??
throwErr(MAILER_SMTP_USER_UNDEFINED),
pass:
env.INFRA.MAILER_SMTP_PASSWORD ??
config.get('MAILER_SMTP_PASSWORD') ??
throwErr(MAILER_SMTP_PASSWORD_UNDEFINED),
},
tls: {
rejectUnauthorized:
(env.INFRA.MAILER_TLS_REJECT_UNAUTHORIZED ??
config.get('MAILER_TLS_REJECT_UNAUTHORIZED')) === 'true',
},
};
}
}

View File

@@ -1,30 +1,51 @@
import { Module } from '@nestjs/common';
import { Global, Module } from '@nestjs/common';
import { MailerModule as NestMailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { MailerService } from './mailer.service';
import { throwErr } from 'src/utils';
import {
MAILER_FROM_ADDRESS_UNDEFINED,
MAILER_SMTP_URL_UNDEFINED,
} from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { loadInfraConfiguration } from 'src/infra-config/helper';
import { getMailerAddressFrom, getTransportOption } from './helper';
@Global()
@Module({
imports: [
NestMailerModule.forRoot({
transport:
process.env.MAILER_SMTP_URL ?? throwErr(MAILER_SMTP_URL_UNDEFINED),
defaults: {
from:
process.env.MAILER_ADDRESS_FROM ??
throwErr(MAILER_FROM_ADDRESS_UNDEFINED),
},
template: {
dir: __dirname + '/templates',
adapter: new HandlebarsAdapter(),
},
}),
],
imports: [],
providers: [MailerService],
exports: [MailerService],
})
export class MailerModule {}
export class MailerModule {
static async register() {
const config = new ConfigService();
const env = await loadInfraConfiguration();
// If mailer SMTP is DISABLED, return the module without any configuration (service, listener, etc.)
if (env.INFRA.MAILER_SMTP_ENABLE !== 'true') {
console.log('Mailer module is disabled');
return {
module: MailerModule,
};
}
// If mailer is ENABLED, return the module with configuration (service, etc.)
// Determine transport configuration based on custom config flag
let transportOption = getTransportOption(env, config);
// Get mailer address from environment or config
const mailerAddressFrom = getMailerAddressFrom(env, config);
return {
module: MailerModule,
imports: [
NestMailerModule.forRoot({
transport: transportOption,
defaults: {
from: mailerAddressFrom,
},
template: {
dir: __dirname + '/templates',
adapter: new HandlebarsAdapter(),
},
}),
],
};
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Optional } from '@nestjs/common';
import {
AdminUserInvitationMailDescription,
MailDescription,
@@ -7,10 +7,14 @@ import {
import { throwErr } from 'src/utils';
import { EMAIL_FAILED } from 'src/errors';
import { MailerService as NestMailerService } from '@nestjs-modules/mailer';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class MailerService {
constructor(private readonly nestMailerService: NestMailerService) {}
constructor(
@Optional() private readonly nestMailerService: NestMailerService,
private readonly configService: ConfigService,
) {}
/**
* Takes an input mail description and spits out the Email subject required for it
@@ -25,7 +29,7 @@ export class MailerService {
): string {
switch (mailDesc.template) {
case 'team-invitation':
return `${mailDesc.variables.invitee} invited you to join ${mailDesc.variables.invite_team_name} in Hoppscotch`;
return `A user has invited you to join a team workspace in Hoppscotch`;
case 'user-invitation':
return 'Sign in to Hoppscotch';
@@ -42,6 +46,8 @@ export class MailerService {
to: string,
mailDesc: MailDescription | UserMagicLinkMailDescription,
) {
if (this.configService.get('INFRA.MAILER_SMTP_ENABLE') !== 'true') return;
try {
await this.nestMailerService.sendMail({
to,
@@ -50,6 +56,7 @@ export class MailerService {
context: mailDesc.variables,
});
} catch (error) {
console.log('Error from sendEmail:', error);
return throwErr(EMAIL_FAILED);
}
}
@@ -64,6 +71,8 @@ export class MailerService {
to: string,
mailDesc: AdminUserInvitationMailDescription,
) {
if (this.configService.get('INFRA.MAILER_SMTP_ENABLE') !== 'true') return;
try {
const res = await this.nestMailerService.sendMail({
to,
@@ -73,6 +82,7 @@ export class MailerService {
});
return res;
} catch (error) {
console.log('Error from sendUserInvitationEmail:', error);
return throwErr(EMAIL_FAILED);
}
}

View File

@@ -27,6 +27,12 @@
color: #3869D4;
}
a.nohighlight {
color: inherit !important;
text-decoration: none !important;
cursor: default !important;
}
a img {
border: none;
}
@@ -458,7 +464,7 @@
<td class="content-cell">
<div class="f-fallback">
<h1>Hi there,</h1>
<p>{{invitee}} with {{invite_team_name}} has invited you to use Hoppscotch to collaborate with them. Click the button below to set up your account and get started:</p>
<p><a class="nohighlight" name="invitee" href="#">{{invitee}}</a> with <a class="nohighlight" name="invite_team_name" href="#">{{invite_team_name}}</a> has invited you to use Hoppscotch to collaborate with them. Click the button below to set up your account and get started:</p>
<!-- Action -->
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
<tr>
@@ -484,7 +490,7 @@
Welcome aboard, <br />
Your friends at Hoppscotch
</p>
<p><strong>P.S.</strong> If you don't associate with {{invitee}} or {{invite_team_name}}, just ignore this email.</p>
<p><strong>P.S.</strong> If you don't associate with <a class="nohighlight" name="invitee" href="#">{{invitee}}</a> or <a class="nohighlight" name="invite_team_name" href="#">{{invite_team_name}}</a>, just ignore this email.</p>
<!-- Sub copy -->
<table class="body-sub">
<tr>

View File

@@ -14,7 +14,7 @@
-->
<style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
body {
width: 100% !important;
@@ -22,19 +22,25 @@
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a.nohighlight {
color: inherit !important;
text-decoration: none !important;
cursor: default !important;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
@@ -47,13 +53,13 @@
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
@@ -61,7 +67,7 @@
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
@@ -69,7 +75,7 @@
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
@@ -77,12 +83,12 @@
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
@@ -91,25 +97,25 @@
font-size: 16px;
line-height: 1.625;
}
p.sub {
font-size: 13px;
}
/* Utilities ------------------------------ */
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
/* Buttons ------------------------------ */
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
@@ -124,7 +130,7 @@
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
@@ -132,7 +138,7 @@
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
.button--red {
background-color: #FF6136;
border-top: 10px solid #FF6136;
@@ -140,7 +146,7 @@
border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
@@ -148,21 +154,21 @@
}
}
/* Attribute list ------------------------------ */
.attributes {
margin: 0 0 21px;
}
.attributes_content {
background-color: #F4F4F7;
padding: 16px;
}
.attributes_item {
padding: 0;
}
/* Related Items ------------------------------ */
.related {
width: 100%;
margin: 0;
@@ -171,31 +177,31 @@
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.related_item {
padding: 10px 0;
color: #CBCCCF;
font-size: 15px;
line-height: 18px;
}
.related_item-title {
display: block;
margin: .5em 0 0;
}
.related_item-thumb {
display: block;
padding-bottom: 10px;
}
.related_heading {
border-top: 1px solid #CBCCCF;
text-align: center;
padding: 25px 0 10px;
}
/* Discount Code ------------------------------ */
.discount {
width: 100%;
margin: 0;
@@ -206,33 +212,33 @@
background-color: #F4F4F7;
border: 2px dashed #CBCCCF;
}
.discount_heading {
text-align: center;
}
.discount_body {
text-align: center;
font-size: 15px;
}
/* Social Icons ------------------------------ */
.social {
width: auto;
}
.social td {
padding: 0;
width: auto;
}
.social_icon {
height: 20px;
margin: 0 8px 10px 8px;
padding: 0;
}
/* Data table ------------------------------ */
.purchase {
width: 100%;
margin: 0;
@@ -241,7 +247,7 @@
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_content {
width: 100%;
margin: 0;
@@ -250,50 +256,50 @@
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_item {
padding: 10px 0;
color: #51545E;
font-size: 15px;
line-height: 18px;
}
.purchase_heading {
padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC;
}
.purchase_heading p {
margin: 0;
color: #85878E;
font-size: 12px;
}
.purchase_footer {
padding-top: 15px;
border-top: 1px solid #EAEAEC;
}
.purchase_total {
margin: 0;
text-align: right;
font-weight: bold;
color: #333333;
}
.purchase_total--label {
padding: 0 15px 0 0;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
p {
color: #51545E;
}
.email-wrapper {
width: 100%;
margin: 0;
@@ -303,7 +309,7 @@
-premailer-cellspacing: 0;
background-color: #F2F4F6;
}
.email-content {
width: 100%;
margin: 0;
@@ -313,16 +319,16 @@
-premailer-cellspacing: 0;
}
/* Masthead ----------------------- */
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead_logo {
width: 94px;
}
.email-masthead_name {
font-size: 16px;
font-weight: bold;
@@ -331,7 +337,7 @@
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
@@ -340,7 +346,7 @@
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 570px;
margin: 0 auto;
@@ -350,7 +356,7 @@
-premailer-cellspacing: 0;
background-color: #FFFFFF;
}
.email-footer {
width: 570px;
margin: 0 auto;
@@ -360,11 +366,11 @@
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #A8AAAF;
}
.body-action {
width: 100%;
margin: 30px auto;
@@ -374,25 +380,25 @@
-premailer-cellspacing: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 45px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,

View File

@@ -2,22 +2,57 @@ import { NestFactory } from '@nestjs/core';
import { json } from 'express';
import { AppModule } from './app.module';
import * as cookieParser from 'cookie-parser';
import { VersioningType } from '@nestjs/common';
import { ValidationPipe, VersioningType } from '@nestjs/common';
import * as session from 'express-session';
import { emitGQLSchemaFile } from './gql-schema';
import { checkEnvironmentAuthProvider } from './utils';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { InfraTokensController } from './infra-token/infra-token.controller';
import { InfraTokenModule } from './infra-token/infra-token.module';
function setupSwagger(app) {
const swaggerDocPath = '/api-docs';
const config = new DocumentBuilder()
.setTitle('Hoppscotch API Documentation')
.setDescription('APIs for external integration')
.addApiKey(
{
type: 'apiKey',
name: 'Authorization',
in: 'header',
scheme: 'bearer',
bearerFormat: 'Bearer',
},
'infra-token',
)
.build();
const document = SwaggerModule.createDocument(app, config, {
include: [InfraTokenModule],
});
SwaggerModule.setup(swaggerDocPath, app, document, {
swaggerOptions: { persistAuthorization: true, ignoreGlobalPrefix: true },
});
}
async function bootstrap() {
console.log(`Running in production: ${process.env.PRODUCTION}`);
console.log(`Port: ${process.env.PORT}`);
checkEnvironmentAuthProvider();
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
console.log(`Running in production: ${configService.get('PRODUCTION')}`);
console.log(`Port: ${configService.get('PORT')}`);
checkEnvironmentAuthProvider(
configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS') ??
configService.get('VITE_ALLOWED_AUTH_PROVIDERS'),
);
app.use(
session({
secret: process.env.SESSION_SECRET,
secret: configService.get('SESSION_SECRET'),
}),
);
@@ -28,18 +63,18 @@ async function bootstrap() {
}),
);
if (process.env.PRODUCTION === 'false') {
if (configService.get('PRODUCTION') === 'false') {
console.log('Enabling CORS with development settings');
app.enableCors({
origin: process.env.WHITELISTED_ORIGINS.split(','),
origin: configService.get('WHITELISTED_ORIGINS').split(','),
credentials: true,
});
} else {
console.log('Enabling CORS with production settings');
app.enableCors({
origin: process.env.WHITELISTED_ORIGINS.split(','),
origin: configService.get('WHITELISTED_ORIGINS').split(','),
credentials: true,
});
}
@@ -47,7 +82,21 @@ async function bootstrap() {
type: VersioningType.URI,
});
app.use(cookieParser());
await app.listen(process.env.PORT || 3170);
app.useGlobalPipes(
new ValidationPipe({
transform: true,
}),
);
await setupSwagger(app);
await app.listen(configService.get('PORT') || 3170);
// Graceful shutdown
process.on('SIGTERM', async () => {
console.info('SIGTERM signal received');
await app.close();
});
}
if (!process.env.GENERATE_GQL_SCHEMA) {

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PosthogService } from './posthog.service';
import { PrismaModule } from 'src/prisma/prisma.module';
@Module({
imports: [PrismaModule],
providers: [PosthogService],
})
export class PosthogModule {}

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