Compare commits
13 Commits
feat/node-
...
chore/dock
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c9f8ed4e8 | ||
|
|
f8bbf320fb | ||
|
|
633d98bbbc | ||
|
|
44fabe6570 | ||
|
|
8acfe8afb0 | ||
|
|
e233f36ce0 | ||
|
|
e1cbe6e003 | ||
|
|
1c35ea6e65 | ||
|
|
6eb0426aca | ||
|
|
fc0c113e00 | ||
|
|
9e595ec594 | ||
|
|
1b1a09c675 | ||
|
|
6454d83486 |
@@ -5,5 +5,5 @@
|
|||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/NicoVIII/devcontainer-features/pnpm:1": {}
|
"ghcr.io/NicoVIII/devcontainer-features/pnpm:1": {}
|
||||||
},
|
},
|
||||||
"postCreateCommand": "cp .env.example .env && pnpm i"
|
"postCreateCommand": "mv .env.example .env && pnpm i"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ SESSION_SECRET='add some secret here'
|
|||||||
|
|
||||||
# Hoppscotch App Domain Config
|
# Hoppscotch App Domain Config
|
||||||
REDIRECT_URL="http://localhost:3000"
|
REDIRECT_URL="http://localhost:3000"
|
||||||
WHITELISTED_ORIGINS="http://localhost:3170,http://localhost:3000,http://localhost:3100"
|
WHITELISTED_ORIGINS = "http://localhost:3170,http://localhost:3000,http://localhost:3100"
|
||||||
VITE_ALLOWED_AUTH_PROVIDERS=GOOGLE,GITHUB,MICROSOFT,EMAIL
|
VITE_ALLOWED_AUTH_PROVIDERS = GOOGLE,GITHUB,MICROSOFT,EMAIL
|
||||||
|
|
||||||
# Google Auth Config
|
# Google Auth Config
|
||||||
GOOGLE_CLIENT_ID="************************************************"
|
GOOGLE_CLIENT_ID="************************************************"
|
||||||
@@ -59,6 +59,3 @@ VITE_BACKEND_API_URL=http://localhost:3170/v1
|
|||||||
# Terms Of Service And Privacy Policy Links (Optional)
|
# Terms Of Service And Privacy Policy Links (Optional)
|
||||||
VITE_APP_TOS_LINK=https://docs.hoppscotch.io/support/terms
|
VITE_APP_TOS_LINK=https://docs.hoppscotch.io/support/terms
|
||||||
VITE_APP_PRIVACY_POLICY_LINK=https://docs.hoppscotch.io/support/privacy
|
VITE_APP_PRIVACY_POLICY_LINK=https://docs.hoppscotch.io/support/privacy
|
||||||
|
|
||||||
# Set to `true` for subpath based access
|
|
||||||
ENABLE_SUBPATH_BASED_ACCESS=false
|
|
||||||
|
|||||||
84
.github/workflows/release-push-docker.yml
vendored
84
.github/workflows/release-push-docker.yml
vendored
@@ -1,84 +0,0 @@
|
|||||||
name: "Push containers to Docker Hub on release"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- '*.*.*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Setup environment
|
|
||||||
run: cp .env.example .env
|
|
||||||
|
|
||||||
- name: Setup QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Setup Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Build and push the backend container
|
|
||||||
uses: docker/build-push-action@v4
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./prod.Dockerfile
|
|
||||||
target: backend
|
|
||||||
push: true
|
|
||||||
platforms: |
|
|
||||||
linux/amd64
|
|
||||||
linux/arm64
|
|
||||||
tags: |
|
|
||||||
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }}:latest
|
|
||||||
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }}:${{ github.ref_name }}
|
|
||||||
|
|
||||||
- name: Build and push the frontend container
|
|
||||||
uses: docker/build-push-action@v4
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./prod.Dockerfile
|
|
||||||
target: app
|
|
||||||
push: true
|
|
||||||
platforms: |
|
|
||||||
linux/amd64
|
|
||||||
linux/arm64
|
|
||||||
tags: |
|
|
||||||
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }}:latest
|
|
||||||
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }}:${{ github.ref_name }}
|
|
||||||
|
|
||||||
- name: Build and push the admin dashboard container
|
|
||||||
uses: docker/build-push-action@v4
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./prod.Dockerfile
|
|
||||||
target: sh_admin
|
|
||||||
push: true
|
|
||||||
platforms: |
|
|
||||||
linux/amd64
|
|
||||||
linux/arm64
|
|
||||||
tags: |
|
|
||||||
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }}:latest
|
|
||||||
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }}:${{ github.ref_name }}
|
|
||||||
|
|
||||||
- name: Build and push the AIO container
|
|
||||||
uses: docker/build-push-action@v4
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./prod.Dockerfile
|
|
||||||
target: aio
|
|
||||||
push: true
|
|
||||||
platforms: |
|
|
||||||
linux/amd64
|
|
||||||
linux/arm64
|
|
||||||
tags: |
|
|
||||||
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_AIO_CONTAINER_NAME }}:latest
|
|
||||||
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_AIO_CONTAINER_NAME }}:${{ github.ref_name }}
|
|
||||||
15
.github/workflows/tests.yml
vendored
15
.github/workflows/tests.yml
vendored
@@ -17,21 +17,22 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Setup environment
|
- name: Setup environment
|
||||||
run: mv .env.example .env
|
run: mv .env.example .env
|
||||||
|
|
||||||
- name: Setup node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node-version }}
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v2.2.4
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 8
|
||||||
run_install: true
|
run_install: true
|
||||||
|
|
||||||
|
- name: Setup node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node }}
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|||||||
2
.github/workflows/ui.yml
vendored
2
.github/workflows/ui.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
# Deploy the ui site with netlify-cli
|
# Deploy the ui site with netlify-cli
|
||||||
- name: Deploy to Netlify (ui)
|
- name: Deploy to Netlify (ui)
|
||||||
run: npx netlify-cli@15.11.0 deploy --dir=packages/hoppscotch-ui/.histoire/dist --prod
|
run: npx netlify-cli deploy --dir=packages/hoppscotch-ui/.histoire/dist --prod
|
||||||
env:
|
env:
|
||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_UI_SITE_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_UI_SITE_ID }}
|
||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -81,7 +81,10 @@ web_modules/
|
|||||||
|
|
||||||
# dotenv environment variable files
|
# dotenv environment variable files
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
.cache
|
.cache
|
||||||
|
|||||||
14
.vscode/extensions.json
vendored
Normal file
14
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"antfu.iconify",
|
||||||
|
"vue.volar",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"editorconfig.editorconfig",
|
||||||
|
"csstools.postcss",
|
||||||
|
"folke.vscode-monorepo-workspace"
|
||||||
|
],
|
||||||
|
"unwantedRecommendations": [
|
||||||
|
"octref.vetur"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@ We as members, contributors, and leaders pledge to make participation in our
|
|||||||
community a harassment-free experience for everyone, regardless of age, body
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
identity and expression, level of experience, education, socio-economic status,
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
identity and orientation.
|
and orientation.
|
||||||
|
|
||||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
diverse, inclusive, and healthy community.
|
diverse, inclusive, and healthy community.
|
||||||
@@ -22,17 +22,17 @@ community include:
|
|||||||
* Giving and gracefully accepting constructive feedback
|
* Giving and gracefully accepting constructive feedback
|
||||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
and learning from the experience
|
and learning from the experience
|
||||||
* Focusing on what is best not just for us as individuals, but for the overall
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
community
|
overall community
|
||||||
|
|
||||||
Examples of unacceptable behavior include:
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
any kind
|
advances of any kind
|
||||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
* Public or private harassment
|
* Public or private harassment
|
||||||
* Publishing others' private information, such as a physical or email address,
|
* Publishing others' private information, such as a physical or email
|
||||||
without their explicit permission
|
address, without their explicit permission
|
||||||
* Other conduct which could reasonably be considered inappropriate in a
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
professional setting
|
professional setting
|
||||||
|
|
||||||
@@ -82,15 +82,15 @@ behavior was inappropriate. A public apology may be requested.
|
|||||||
|
|
||||||
### 2. Warning
|
### 2. Warning
|
||||||
|
|
||||||
**Community Impact**: A violation through a single incident or series of
|
**Community Impact**: A violation through a single incident or series
|
||||||
actions.
|
of actions.
|
||||||
|
|
||||||
**Consequence**: A warning with consequences for continued behavior. No
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
interaction with the people involved, including unsolicited interaction with
|
interaction with the people involved, including unsolicited interaction with
|
||||||
those enforcing the Code of Conduct, for a specified period of time. This
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
includes avoiding interactions in community spaces as well as external channels
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
like social media. Violating these terms may lead to a temporary or permanent
|
like social media. Violating these terms may lead to a temporary or
|
||||||
ban.
|
permanent ban.
|
||||||
|
|
||||||
### 3. Temporary Ban
|
### 3. Temporary Ban
|
||||||
|
|
||||||
@@ -106,27 +106,23 @@ Violating these terms may lead to a permanent ban.
|
|||||||
### 4. Permanent Ban
|
### 4. Permanent Ban
|
||||||
|
|
||||||
**Community Impact**: Demonstrating a pattern of violation of community
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
standards, including sustained inappropriate behavior, harassment of an
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
individual, or aggression toward or disparagement of classes of individuals.
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
community.
|
the community.
|
||||||
|
|
||||||
## Attribution
|
## Attribution
|
||||||
|
|
||||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
version 2.1, available at
|
version 2.0, available at
|
||||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
Community Impact Guidelines were inspired by
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
For answers to common questions about this code of conduct, see the FAQ at
|
|
||||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
|
||||||
[https://www.contributor-covenant.org/translations][translations].
|
|
||||||
|
|
||||||
[homepage]: https://www.contributor-covenant.org
|
[homepage]: https://www.contributor-covenant.org
|
||||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
|
||||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
[FAQ]: https://www.contributor-covenant.org/faq
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
[translations]: https://www.contributor-covenant.org/translations
|
https://www.contributor-covenant.org/translations.
|
||||||
|
|||||||
190
README.md
190
README.md
@@ -2,18 +2,23 @@
|
|||||||
<a href="https://hoppscotch.io">
|
<a href="https://hoppscotch.io">
|
||||||
<img
|
<img
|
||||||
src="https://avatars.githubusercontent.com/u/56705483"
|
src="https://avatars.githubusercontent.com/u/56705483"
|
||||||
alt="Hoppscotch"
|
alt="Hoppscotch Logo"
|
||||||
height="64"
|
height="64"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<h3>
|
<br />
|
||||||
|
<p>
|
||||||
|
<h3>
|
||||||
|
<b>
|
||||||
|
Hoppscotch
|
||||||
|
</b>
|
||||||
|
</h3>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
<b>
|
<b>
|
||||||
Hoppscotch
|
Open source API development ecosystem
|
||||||
</b>
|
</b>
|
||||||
</h3>
|
</p>
|
||||||
<b>
|
|
||||||
Open Source API Development Ecosystem
|
|
||||||
</b>
|
|
||||||
<p>
|
<p>
|
||||||
|
|
||||||
[](CODE_OF_CONDUCT.md) [](https://hoppscotch.io) [](https://github.com/hoppscotch/hoppscotch/actions) [](https://twitter.com/share?text=%F0%9F%91%BD%20Hoppscotch%20%E2%80%A2%20Open%20source%20API%20development%20ecosystem%20-%20Helps%20you%20create%20requests%20faster,%20saving%20precious%20time%20on%20development.&url=https://hoppscotch.io&hashtags=hoppscotch&via=hoppscotch_io)
|
[](CODE_OF_CONDUCT.md) [](https://hoppscotch.io) [](https://github.com/hoppscotch/hoppscotch/actions) [](https://twitter.com/share?text=%F0%9F%91%BD%20Hoppscotch%20%E2%80%A2%20Open%20source%20API%20development%20ecosystem%20-%20Helps%20you%20create%20requests%20faster,%20saving%20precious%20time%20on%20development.&url=https://hoppscotch.io&hashtags=hoppscotch&via=hoppscotch_io)
|
||||||
@@ -29,18 +34,23 @@
|
|||||||
</p>
|
</p>
|
||||||
<br />
|
<br />
|
||||||
<p>
|
<p>
|
||||||
<a href="https://hoppscotch.io">
|
<a href="https://hoppscotch.io/#gh-light-mode-only" target="_blank">
|
||||||
<picture>
|
<img
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/hoppscotch-common/public/images/banner-dark.png">
|
src="./packages/hoppscotch-common/public/images/banner-light.png"
|
||||||
<source media="(prefers-color-scheme: light)" srcset="./packages/hoppscotch-common/public/images/banner-light.png">
|
alt="Hoppscotch"
|
||||||
<img alt="Hoppscotch" src="./packages/hoppscotch-common/public/images/banner-dark.png">
|
width="100%"
|
||||||
</picture>
|
/>
|
||||||
|
</a>
|
||||||
|
<a href="https://hoppscotch.io/#gh-dark-mode-only" target="_blank">
|
||||||
|
<img
|
||||||
|
src="./packages/hoppscotch-common/public/images/banner-dark.png"
|
||||||
|
alt="Hoppscotch"
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
_We highly recommend you take a look at the [**Hoppscotch Documentation**](https://docs.hoppscotch.io) to learn more about the app._
|
|
||||||
|
|
||||||
#### **Support**
|
#### **Support**
|
||||||
|
|
||||||
[](https://hoppscotch.io/discord) [](https://hoppscotch.io/telegram) [](https://github.com/hoppscotch/hoppscotch/discussions)
|
[](https://hoppscotch.io/discord) [](https://hoppscotch.io/telegram) [](https://github.com/hoppscotch/hoppscotch/discussions)
|
||||||
@@ -49,9 +59,9 @@ _We highly recommend you take a look at the [**Hoppscotch Documentation**](https
|
|||||||
|
|
||||||
❤️ **Lightweight:** Crafted with minimalistic UI design.
|
❤️ **Lightweight:** Crafted with minimalistic UI design.
|
||||||
|
|
||||||
⚡️ **Fast:** Send requests and get responses in real time.
|
⚡️ **Fast:** Send requests and get/copy responses in real-time.
|
||||||
|
|
||||||
🗄️ **HTTP Methods:** Request methods define the type of action you are requesting to be performed.
|
**HTTP Methods**
|
||||||
|
|
||||||
- `GET` - Requests retrieve resource information
|
- `GET` - Requests retrieve resource information
|
||||||
- `POST` - The server creates a new entry in a database
|
- `POST` - The server creates a new entry in a database
|
||||||
@@ -64,15 +74,17 @@ _We highly recommend you take a look at the [**Hoppscotch Documentation**](https
|
|||||||
- `TRACE` - Performs a message loop-back test along the path to the target resource
|
- `TRACE` - Performs a message loop-back test along the path to the target resource
|
||||||
- `<custom>` - Some APIs use custom request methods such as `LIST`. Type in your custom methods.
|
- `<custom>` - Some APIs use custom request methods such as `LIST`. Type in your custom methods.
|
||||||
|
|
||||||
🌈 **Theming:** Customizable combinations for background, foreground, and accent colors — [customize now](https://hoppscotch.io/settings).
|
🌈 **Make it yours:** Customizable combinations for background, foreground, and accent colors — [customize now](https://hoppscotch.io/settings).
|
||||||
|
|
||||||
- Choose a theme: System preference, Light, Dark, and Black
|
**Theming**
|
||||||
- Choose accent colors: Green, Teal, Blue, Indigo, Purple, Yellow, Orange, Red, and Pink
|
|
||||||
|
- Choose a theme: System (default), Light, Dark, and Black
|
||||||
|
- Choose accent color: Green (default), Teal, Blue, Indigo, Purple, Yellow, Orange, Red, and Pink
|
||||||
- Distraction-free Zen mode
|
- Distraction-free Zen mode
|
||||||
|
|
||||||
_Customized themes are synced with your cloud/local session._
|
_Customized themes are synced with cloud / local session_
|
||||||
|
|
||||||
🔥 **PWA:** Install as a [Progressive Web App](https://web.dev/progressive-web-apps) on your device.
|
🔥 **PWA:** Install as a [PWA](https://web.dev/what-are-pwas/) on your device.
|
||||||
|
|
||||||
- Instant loading with Service Workers
|
- Instant loading with Service Workers
|
||||||
- Offline support
|
- Offline support
|
||||||
@@ -95,7 +107,7 @@ _Customized themes are synced with your cloud/local session._
|
|||||||
|
|
||||||
📡 **Server-Sent Events:** Receive a stream of updates from a server over an HTTP connection without resorting to polling.
|
📡 **Server-Sent Events:** Receive a stream of updates from a server over an HTTP connection without resorting to polling.
|
||||||
|
|
||||||
🌩 **Socket.IO:** Send and Receive data with the SocketIO server.
|
🌩 **Socket.IO:** Send and Receive data with SocketIO server.
|
||||||
|
|
||||||
🦟 **MQTT:** Subscribe and Publish to topics of an MQTT Broker.
|
🦟 **MQTT:** Subscribe and Publish to topics of an MQTT Broker.
|
||||||
|
|
||||||
@@ -115,7 +127,7 @@ _Customized themes are synced with your cloud/local session._
|
|||||||
- OAuth 2.0
|
- OAuth 2.0
|
||||||
- OIDC Access Token/PKCE
|
- OIDC Access Token/PKCE
|
||||||
|
|
||||||
📢 **Headers:** Describes the format the body of your request is being sent in.
|
📢 **Headers:** Describes the format the body of your request is being sent as.
|
||||||
|
|
||||||
📫 **Parameters:** Use request parameters to set varying parts in simulated requests.
|
📫 **Parameters:** Use request parameters to set varying parts in simulated requests.
|
||||||
|
|
||||||
@@ -125,14 +137,14 @@ _Customized themes are synced with your cloud/local session._
|
|||||||
- FormData, JSON, and many more
|
- FormData, JSON, and many more
|
||||||
- Toggle between key-value and RAW input parameter list
|
- Toggle between key-value and RAW input parameter list
|
||||||
|
|
||||||
📮 **Response:** Contains the status line, headers, and the message/response body.
|
👋 **Response:** Contains the status line, headers, and the message/response body.
|
||||||
|
|
||||||
- Copy the response to the clipboard
|
- Copy response to clipboard
|
||||||
- Download the response as a file
|
- Download response as a file
|
||||||
- View response headers
|
- View response headers
|
||||||
- View raw and preview HTML, image, JSON, and XML responses
|
- View raw and preview of HTML, image, JSON, XML responses
|
||||||
|
|
||||||
⏰ **History:** Request entries are synced with your cloud/local session storage.
|
⏰ **History:** Request entries are synced with cloud / local session storage to restore with a single click.
|
||||||
|
|
||||||
📁 **Collections:** Keep your API requests organized with collections and folders. Reuse them with a single click.
|
📁 **Collections:** Keep your API requests organized with collections and folders. Reuse them with a single click.
|
||||||
|
|
||||||
@@ -140,32 +152,7 @@ _Customized themes are synced with your cloud/local session._
|
|||||||
- Nested folders
|
- Nested folders
|
||||||
- Export and import as a file or GitHub gist
|
- Export and import as a file or GitHub gist
|
||||||
|
|
||||||
_Collections are synced with your cloud/local session storage._
|
_Collections are synced with cloud / local session storage_
|
||||||
|
|
||||||
📜 **Pre-Request Scripts:** Snippets of code associated with a request that is executed before the request is sent.
|
|
||||||
|
|
||||||
- Set environment variables
|
|
||||||
- Include timestamp in the request headers
|
|
||||||
- Send a random alphanumeric string in the URL parameters
|
|
||||||
- Any JavaScript functions
|
|
||||||
|
|
||||||
👨👩👧👦 **Teams:** Helps you collaborate across your teams to design, develop, and test APIs faster.
|
|
||||||
|
|
||||||
- Create unlimited teams
|
|
||||||
- Create unlimited shared collections
|
|
||||||
- Create unlimited team members
|
|
||||||
- Role-based access control
|
|
||||||
- Cloud sync
|
|
||||||
- Multiple devices
|
|
||||||
|
|
||||||
👥 **Workspaces:** Organize your personal and team collections environments into workspaces. Easily switch between workspaces to manage multiple projects.
|
|
||||||
|
|
||||||
- Create unlimited workspaces
|
|
||||||
- Switch between personal and team workspaces
|
|
||||||
|
|
||||||
⌨️ **Keyboard Shortcuts:** Optimized for efficiency.
|
|
||||||
|
|
||||||
> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/documentation/features/shortcuts)**
|
|
||||||
|
|
||||||
🌐 **Proxy:** Enable Proxy Mode from Settings to access blocked APIs.
|
🌐 **Proxy:** Enable Proxy Mode from Settings to access blocked APIs.
|
||||||
|
|
||||||
@@ -174,31 +161,60 @@ _Collections are synced with your cloud/local session storage._
|
|||||||
- Access APIs served in non-HTTPS (`http://`) endpoints
|
- Access APIs served in non-HTTPS (`http://`) endpoints
|
||||||
- Use your Proxy URL
|
- Use your Proxy URL
|
||||||
|
|
||||||
_Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/hoppscotch/proxyscotch)** - **[Privacy Policy](https://docs.hoppscotch.io/support/privacy)**._
|
_Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/hoppscotch/proxyscotch)** - **[Privacy Policy](https://docs.hoppscotch.io/support/privacy)**_
|
||||||
|
|
||||||
|
📜 **Pre-Request Scripts β:** Snippets of code associated with a request that is executed before the request is sent.
|
||||||
|
|
||||||
|
- Set environment variables
|
||||||
|
- Include timestamp in the request headers
|
||||||
|
- Send a random alphanumeric string in the URL parameters
|
||||||
|
- Any JavaScript functions
|
||||||
|
|
||||||
|
📄 **API Documentation:** Create and share dynamic API documentation easily, quickly.
|
||||||
|
|
||||||
|
1. Add your requests to Collections and Folders
|
||||||
|
2. Export Collections and easily share your APIs with the rest of your team
|
||||||
|
3. Import Collections and Generate Documentation on-the-go
|
||||||
|
|
||||||
|
⌨️ **Keyboard Shortcuts:** Optimized for efficiency.
|
||||||
|
|
||||||
|
> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/documentation/features/shortcuts)**
|
||||||
|
|
||||||
🌎 **i18n:** Experience the app in your language.
|
🌎 **i18n:** Experience the app in your language.
|
||||||
|
|
||||||
Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) for details on our [`CODE OF CONDUCT`](CODE_OF_CONDUCT.md) and the process for submitting pull requests to us.
|
Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) for details on our [`CODE OF CONDUCT`](CODE_OF_CONDUCT.md), and the process for submitting pull requests to us.
|
||||||
|
|
||||||
☁️ **Auth + Sync:** Sign in and sync your data in real-time across all your devices.
|
📦 **Add-ons:** Official add-ons for hoppscotch.
|
||||||
|
|
||||||
**Sign in with:**
|
- **[Proxy](https://github.com/hoppscotch/proxyscotch)** - A simple proxy server created for Hoppscotch
|
||||||
|
- **[CLI β](https://github.com/hoppscotch/hopp-cli)** - A CLI solution for Hoppscotch
|
||||||
|
- **[Browser Extensions](https://github.com/hoppscotch/hoppscotch-extension)** - Browser extensions that simplifies access to Hoppscotch
|
||||||
|
|
||||||
|
[ **Firefox**](https://addons.mozilla.org/en-US/firefox/addon/hoppscotch) | [ **Chrome**](https://chrome.google.com/webstore/detail/hoppscotch-extension-for-c/amknoiejhlmhancpahfcfcfhllgkpbld)
|
||||||
|
|
||||||
|
> **Extensions fixes `CORS` issues.**
|
||||||
|
|
||||||
|
- **[Hopp-Doc-Gen](https://github.com/hoppscotch/hopp-doc-gen)** - An API doc generator CLI for Hoppscotch
|
||||||
|
|
||||||
|
_Add-ons are developed and maintained under **[Hoppscotch Organization](https://github.com/hoppscotch)**._
|
||||||
|
|
||||||
|
☁️ **Auth + Sync:** Sign in and sync your data in real-time.
|
||||||
|
|
||||||
|
**Sign in with**
|
||||||
|
|
||||||
- GitHub
|
- GitHub
|
||||||
- Google
|
- Google
|
||||||
- Microsoft
|
- Microsoft
|
||||||
- Email
|
- Email
|
||||||
- SSO (Single Sign-On)[^EE]
|
|
||||||
|
|
||||||
**🔄 Synchronize your data:** Handoff to continue tasks on your other devices.
|
**Synchronize your data**
|
||||||
|
|
||||||
- Workspaces
|
|
||||||
- History
|
- History
|
||||||
- Collections
|
- Collections
|
||||||
- Environments
|
- Environments
|
||||||
- Settings
|
- Settings
|
||||||
|
|
||||||
✅ **Post-Request Tests:** Write tests associated with a request that is executed after the request's response.
|
✅ **Post-Request Tests β:** Write tests associated with a request that is executed after the request's response.
|
||||||
|
|
||||||
- Check the status code as an integer
|
- Check the status code as an integer
|
||||||
- Filter response headers
|
- Filter response headers
|
||||||
@@ -206,7 +222,7 @@ Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) f
|
|||||||
- Set environment variables
|
- Set environment variables
|
||||||
- Write JavaScript code
|
- Write JavaScript code
|
||||||
|
|
||||||
🌱 **Environments:** Environment variables allow you to store and reuse values in your requests and scripts.
|
🌱 **Environments** : Environment variables allow you to store and reuse values in your requests and scripts.
|
||||||
|
|
||||||
- Unlimited environments and variables
|
- Unlimited environments and variables
|
||||||
- Initialize through the pre-request script
|
- Initialize through the pre-request script
|
||||||
@@ -225,31 +241,22 @@ Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) f
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
👨👩👧👦 **Teams β:** Helps you collaborate across your team to design, develop, and test APIs faster.
|
||||||
|
|
||||||
|
- Unlimited teams
|
||||||
|
- Unlimited shared collections
|
||||||
|
- Unlimited team members
|
||||||
|
- Role-based access control
|
||||||
|
- Cloud sync
|
||||||
|
- Multiple devices
|
||||||
|
|
||||||
🚚 **Bulk Edit:** Edit key-value pairs in bulk.
|
🚚 **Bulk Edit:** Edit key-value pairs in bulk.
|
||||||
|
|
||||||
- Entries are separated by newline
|
- Entries are separated by newline
|
||||||
- Keys and values are separated by `:`
|
- Keys and values are separated by `:`
|
||||||
- Prepend `#` to any row you want to add but keep disabled
|
- Prepend `#` to any row you want to add but keep disabled
|
||||||
|
|
||||||
🎛️ **Admin dashboard:** Manage your team and invite members.
|
**For more features, please read our [documentation](https://docs.hoppscotch.io).**
|
||||||
|
|
||||||
- Insights
|
|
||||||
- Manage users
|
|
||||||
- Manage teams
|
|
||||||
|
|
||||||
📦 **Add-ons:** Official add-ons 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.
|
|
||||||
|
|
||||||
[ **Firefox**](https://addons.mozilla.org/en-US/firefox/addon/hoppscotch) | [ **Chrome**](https://chrome.google.com/webstore/detail/hoppscotch-extension-for-c/amknoiejhlmhancpahfcfcfhllgkpbld)
|
|
||||||
|
|
||||||
> **Extensions fix `CORS` issues.**
|
|
||||||
|
|
||||||
_Add-ons are developed and maintained under **[Hoppscotch Organization](https://github.com/hoppscotch)**._
|
|
||||||
|
|
||||||
**For a complete list of features, please read our [documentation](https://docs.hoppscotch.io).**
|
|
||||||
|
|
||||||
## **Demo**
|
## **Demo**
|
||||||
|
|
||||||
@@ -261,9 +268,18 @@ _Add-ons are developed and maintained under **[Hoppscotch Organization](https://
|
|||||||
2. Click "Send" to simulate the request
|
2. Click "Send" to simulate the request
|
||||||
3. View the response
|
3. View the response
|
||||||
|
|
||||||
|
## **Built with**
|
||||||
|
|
||||||
|
- [HTML](https://developer.mozilla.org/en-US/docs/Web/HTML)
|
||||||
|
- [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS), [SCSS](https://sass-lang.com), [Windi CSS](https://windicss.org)
|
||||||
|
- [JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript)
|
||||||
|
- [TypeScript](https://www.typescriptlang.org)
|
||||||
|
- [Vue](https://vuejs.org)
|
||||||
|
- [Vite](https://vitejs.dev)
|
||||||
|
|
||||||
## **Developing**
|
## **Developing**
|
||||||
|
|
||||||
Follow our [self-hosting documentation](https://docs.hoppscotch.io/documentation/self-host/getting-started) to get started with the development environment.
|
Follow our [self-hosting guide](https://docs.hoppscotch.io/documentation/self-host/getting-started) to get started with the development environment.
|
||||||
|
|
||||||
## **Contributing**
|
## **Contributing**
|
||||||
|
|
||||||
@@ -281,7 +297,7 @@ See the [`CHANGELOG`](CHANGELOG.md) file for details.
|
|||||||
|
|
||||||
## **Authors**
|
## **Authors**
|
||||||
|
|
||||||
This project owes its existence to the collective efforts of all those who contribute — [contribute now](CONTRIBUTING.md).
|
This project exists thanks to all the people who contribute — [contribute](CONTRIBUTING.md).
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://github.com/hoppscotch/hoppscotch/graphs/contributors">
|
<a href="https://github.com/hoppscotch/hoppscotch/graphs/contributors">
|
||||||
@@ -293,6 +309,4 @@ This project owes its existence to the collective efforts of all those who contr
|
|||||||
|
|
||||||
## **License**
|
## **License**
|
||||||
|
|
||||||
This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) — see the [`LICENSE`](LICENSE) file for details.
|
This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [`LICENSE`](LICENSE) file for details.
|
||||||
|
|
||||||
[^EE]: Enterprise edition feature. [Learn more](https://docs.hoppscotch.io/documentation/self-host/getting-started).
|
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
This document outlines security procedures and general policies for the Hoppscotch project.
|
This document outlines security procedures and general policies for the Hoppscotch project.
|
||||||
|
|
||||||
- [Security Policy](#security-policy)
|
1. [Reporting a security vulnerability](#reporting-a-security-vulnerability)
|
||||||
- [Reporting a security vulnerability](#reporting-a-security-vulnerability)
|
3. [Incident response process](#incident-response-process)
|
||||||
- [Incident response process](#incident-response-process)
|
|
||||||
|
|
||||||
## Reporting a security vulnerability
|
## Reporting a security vulnerability
|
||||||
|
|
||||||
|
|||||||
@@ -9,24 +9,26 @@ Before you start working on a new language, please look through the [open pull r
|
|||||||
if there is no existing translation, you can create a new one by following these steps:
|
if there is no existing translation, you can create a new one by following these steps:
|
||||||
|
|
||||||
1. **[Fork the repository](https://github.com/hoppscotch/hoppscotch/fork).**
|
1. **[Fork the repository](https://github.com/hoppscotch/hoppscotch/fork).**
|
||||||
2. **Checkout the `main` branch for latest translations.**
|
2. **Checkout the `i18n` branch for latest translations.**
|
||||||
3. **Create a new branch for your translation with base branch `main`.**
|
3. **Create a new branch for your translation with base branch `i18n`.**
|
||||||
4. **Create target language file in the [`/packages/hoppscotch-common/locales`](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-common/locales) directory.**
|
4. **Create target language file in the [`/packages/hoppscotch-common/locales`](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-common/locales) directory.**
|
||||||
5. **Copy the contents of the source file [`/packages/hoppscotch-common/locales/en.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/locales/en.json) to the target language file.**
|
5. **Copy the contents of the source file [`/packages/hoppscotch-common/locales/en.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/locales/en.json) to the target language file.**
|
||||||
6. **Translate the strings in the target language file.**
|
6. **Translate the strings in the target language file.**
|
||||||
7. **Add your language entry to [`/packages/hoppscotch-common/languages.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/languages.json).**
|
7. **Add your language entry to [`/packages/hoppscotch-common/languages.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/languages.json).**
|
||||||
8. **Save and commit changes.**
|
8. **Save & commit changes.**
|
||||||
9. **Send a pull request.**
|
9. **Send a pull request.**
|
||||||
|
|
||||||
_You may send a pull request before all steps above are complete: e.g., you may want to ask for help with translations, or getting tests to pass. However, your pull request will not be merged until all steps above are complete._
|
_You may send a pull request before all steps above are complete: e.g., you may want to ask for help with translations, or getting tests to pass. However, your pull request will not be merged until all steps above are complete._
|
||||||
|
|
||||||
|
`i18n` branch will be merged into `main` branch once every week.
|
||||||
|
|
||||||
Completing an initial translation of the whole site is a fairly large task. One way to break that task up is to work with other translators through pull requests on your fork. You can also [add collaborators to your fork](https://help.github.com/en/github/setting-up-and-managing-your-github-user-account/inviting-collaborators-to-a-personal-repository) if you'd like to invite other translators to commit directly to your fork and share responsibility for merging pull requests.
|
Completing an initial translation of the whole site is a fairly large task. One way to break that task up is to work with other translators through pull requests on your fork. You can also [add collaborators to your fork](https://help.github.com/en/github/setting-up-and-managing-your-github-user-account/inviting-collaborators-to-a-personal-repository) if you'd like to invite other translators to commit directly to your fork and share responsibility for merging pull requests.
|
||||||
|
|
||||||
## Updating a translation
|
## Updating a translation
|
||||||
|
|
||||||
### Corrections
|
### Corrections
|
||||||
|
|
||||||
If you notice spelling or grammar errors, typos, or opportunities for better phrasing, open a pull request with your suggested fix. If you see a problem that you aren't sure of or don't have time to fix, [open an issue](https://github.com/hoppscotch/hoppscotch/issues/new/choose).
|
If you notice spelling or grammar errors, typos, or opportunities for better phrasing, open a pull request with your suggested fix. If you see a problem that you aren't sure of or don't have time to fix, open an issue.
|
||||||
|
|
||||||
### Broken links
|
### Broken links
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
:3000 {
|
|
||||||
try_files {path} /
|
|
||||||
root * /site/selfhost-web
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
|
|
||||||
:3100 {
|
|
||||||
try_files {path} /
|
|
||||||
root * /site/sh-admin-multiport-setup
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
|
|
||||||
:3170 {
|
|
||||||
reverse_proxy localhost:8080
|
|
||||||
}
|
|
||||||
|
|
||||||
:80 {
|
|
||||||
respond 404
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
:3000 {
|
|
||||||
respond 404
|
|
||||||
}
|
|
||||||
|
|
||||||
:3100 {
|
|
||||||
respond 404
|
|
||||||
}
|
|
||||||
|
|
||||||
:3170 {
|
|
||||||
reverse_proxy localhost:8080
|
|
||||||
}
|
|
||||||
|
|
||||||
:80 {
|
|
||||||
# Serve the `selfhost-web` SPA by default
|
|
||||||
root * /site/selfhost-web
|
|
||||||
file_server
|
|
||||||
|
|
||||||
handle_path /admin* {
|
|
||||||
root * /site/sh-admin-subpath-access
|
|
||||||
file_server
|
|
||||||
|
|
||||||
# Ensures any non-existent file in the server is routed to the SPA
|
|
||||||
try_files {path} /
|
|
||||||
}
|
|
||||||
|
|
||||||
# Handle requests under `/backend*` path
|
|
||||||
handle_path /backend* {
|
|
||||||
reverse_proxy localhost:8080
|
|
||||||
}
|
|
||||||
|
|
||||||
# Catch-all route for unknown paths, serves `selfhost-web` SPA
|
|
||||||
handle {
|
|
||||||
root * /site/selfhost-web
|
|
||||||
file_server
|
|
||||||
try_files {path} /
|
|
||||||
}
|
|
||||||
}
|
|
||||||
11
aio.Caddyfile
Normal file
11
aio.Caddyfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
:3000 {
|
||||||
|
try_files {path} /
|
||||||
|
root * /site/selfhost-web
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
:3100 {
|
||||||
|
try_files {path} /
|
||||||
|
root * /site/sh-admin
|
||||||
|
file_server
|
||||||
|
}
|
||||||
@@ -49,8 +49,7 @@ execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`)
|
|||||||
|
|
||||||
fs.rmSync("build.env")
|
fs.rmSync("build.env")
|
||||||
|
|
||||||
const caddyFileName = process.env.ENABLE_SUBPATH_BASED_ACCESS === 'true' ? 'aio-subpath-access.Caddyfile' : 'aio-multiport-setup.Caddyfile'
|
const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"], "App/Admin Dashboard Caddy")
|
||||||
const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", `/etc/caddy/${caddyFileName}`, "--adapter", "caddyfile"], "App/Admin Dashboard Caddy")
|
|
||||||
const backendProcess = runChildProcessWithPrefix("pnpm", ["run", "start:prod"], "Backend Server")
|
const backendProcess = runChildProcessWithPrefix("pnpm", ["run", "start:prod"], "Backend Server")
|
||||||
|
|
||||||
caddyProcess.on("exit", (code) => {
|
caddyProcess.on("exit", (code) => {
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
# 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
|
|
||||||
|
|
||||||
@@ -7,108 +7,6 @@ services:
|
|||||||
# This service runs the backend app in the port 3170
|
# This service runs the backend app in the port 3170
|
||||||
hoppscotch-backend:
|
hoppscotch-backend:
|
||||||
container_name: hoppscotch-backend
|
container_name: hoppscotch-backend
|
||||||
build:
|
|
||||||
dockerfile: prod.Dockerfile
|
|
||||||
context: .
|
|
||||||
target: backend
|
|
||||||
env_file:
|
|
||||||
- ./.env
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
|
|
||||||
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
|
|
||||||
- PORT=8080
|
|
||||||
volumes:
|
|
||||||
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
|
|
||||||
# - ./packages/hoppscotch-backend/:/usr/src/app
|
|
||||||
- /usr/src/app/node_modules/
|
|
||||||
depends_on:
|
|
||||||
hoppscotch-db:
|
|
||||||
condition: service_healthy
|
|
||||||
ports:
|
|
||||||
- "3180:80"
|
|
||||||
- "3170:3170"
|
|
||||||
|
|
||||||
# The main hoppscotch app. This will be hosted at port 3000
|
|
||||||
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
|
|
||||||
# the SH admin dashboard server at packages/hoppscotch-selfhost-web/Caddyfile
|
|
||||||
hoppscotch-app:
|
|
||||||
container_name: hoppscotch-app
|
|
||||||
build:
|
|
||||||
dockerfile: prod.Dockerfile
|
|
||||||
context: .
|
|
||||||
target: app
|
|
||||||
env_file:
|
|
||||||
- ./.env
|
|
||||||
depends_on:
|
|
||||||
- hoppscotch-backend
|
|
||||||
ports:
|
|
||||||
- "3080:80"
|
|
||||||
- "3000:3000"
|
|
||||||
|
|
||||||
# The Self Host dashboard for managing the app. This will be hosted at port 3100
|
|
||||||
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
|
|
||||||
# the SH admin dashboard server at packages/hoppscotch-sh-admin/Caddyfile
|
|
||||||
hoppscotch-sh-admin:
|
|
||||||
container_name: hoppscotch-sh-admin
|
|
||||||
build:
|
|
||||||
dockerfile: prod.Dockerfile
|
|
||||||
context: .
|
|
||||||
target: sh_admin
|
|
||||||
env_file:
|
|
||||||
- ./.env
|
|
||||||
depends_on:
|
|
||||||
- hoppscotch-backend
|
|
||||||
ports:
|
|
||||||
- "3280:80"
|
|
||||||
- "3100:3100"
|
|
||||||
|
|
||||||
# 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: .
|
|
||||||
target: aio
|
|
||||||
env_file:
|
|
||||||
- ./.env
|
|
||||||
depends_on:
|
|
||||||
hoppscotch-db:
|
|
||||||
condition: service_healthy
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
- "3100:3100"
|
|
||||||
- "3170:3170"
|
|
||||||
- "3080:80"
|
|
||||||
|
|
||||||
# The preset DB service, you can delete/comment the below lines if
|
|
||||||
# you are using an external postgres instance
|
|
||||||
# This will be exposed at port 5432
|
|
||||||
hoppscotch-db:
|
|
||||||
image: postgres:15
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
user: postgres
|
|
||||||
environment:
|
|
||||||
# The default user defined by the docker image
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
# NOTE: Please UPDATE THIS PASSWORD!
|
|
||||||
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
|
|
||||||
|
|
||||||
# All the services listed below are deprececated
|
|
||||||
hoppscotch-old-backend:
|
|
||||||
container_name: hoppscotch-old-backend
|
|
||||||
build:
|
build:
|
||||||
dockerfile: packages/hoppscotch-backend/Dockerfile
|
dockerfile: packages/hoppscotch-backend/Dockerfile
|
||||||
context: .
|
context: .
|
||||||
@@ -130,26 +28,54 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3170:3000"
|
- "3170:3000"
|
||||||
|
|
||||||
hoppscotch-old-app:
|
# The main hoppscotch app. This will be hosted at port 3000
|
||||||
container_name: hoppscotch-old-app
|
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
|
||||||
|
# the SH admin dashboard server at packages/hoppscotch-selfhost-web/Caddyfile
|
||||||
|
hoppscotch-app:
|
||||||
|
container_name: hoppscotch-app
|
||||||
build:
|
build:
|
||||||
dockerfile: packages/hoppscotch-selfhost-web/Dockerfile
|
dockerfile: packages/hoppscotch-selfhost-web/Dockerfile
|
||||||
context: .
|
context: .
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
depends_on:
|
depends_on:
|
||||||
- hoppscotch-old-backend
|
- hoppscotch-backend
|
||||||
ports:
|
ports:
|
||||||
- "3000:8080"
|
- "3000:8080"
|
||||||
|
|
||||||
hoppscotch-old-sh-admin:
|
# The Self Host dashboard for managing the app. This will be hosted at port 3100
|
||||||
container_name: hoppscotch-old-sh-admin
|
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
|
||||||
|
# the SH admin dashboard server at packages/hoppscotch-sh-admin/Caddyfile
|
||||||
|
hoppscotch-sh-admin:
|
||||||
|
container_name: hoppscotch-sh-admin
|
||||||
build:
|
build:
|
||||||
dockerfile: packages/hoppscotch-sh-admin/Dockerfile
|
dockerfile: packages/hoppscotch-sh-admin/Dockerfile
|
||||||
context: .
|
context: .
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
depends_on:
|
depends_on:
|
||||||
- hoppscotch-old-backend
|
- hoppscotch-backend
|
||||||
ports:
|
ports:
|
||||||
- "3100:8080"
|
- "3100:8080"
|
||||||
|
|
||||||
|
# The preset DB service, you can delete/comment the below lines if
|
||||||
|
# you are using an external postgres instance
|
||||||
|
# This will be exposed at port 5432
|
||||||
|
hoppscotch-db:
|
||||||
|
image: postgres:15
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
user: postgres
|
||||||
|
environment:
|
||||||
|
# The default user defined by the docker image
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
# NOTE: Please UPDATE THIS PASSWORD!
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
curlCheck() {
|
|
||||||
if ! curl -s --head "$1" | head -n 1 | grep -q "HTTP/1.[01] [23].."; then
|
|
||||||
echo "URL request failed!"
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
echo "URL request succeeded!"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
29
package.json
29
package.json
@@ -22,26 +22,15 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./packages/*"
|
"./packages/*"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"dependencies": {
|
||||||
"@commitlint/cli": "16.3.0",
|
"husky": "^7.0.4",
|
||||||
"@commitlint/config-conventional": "16.2.4",
|
"lint-staged": "^12.3.8"
|
||||||
"@hoppscotch/ui": "0.1.0",
|
|
||||||
"@types/node": "17.0.27",
|
|
||||||
"cross-env": "7.0.3",
|
|
||||||
"http-server": "14.1.1",
|
|
||||||
"husky": "7.0.4",
|
|
||||||
"lint-staged": "12.4.0"
|
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"devDependencies": {
|
||||||
"overrides": {
|
"@commitlint/cli": "^16.2.3",
|
||||||
"vue": "3.3.9"
|
"@commitlint/config-conventional": "^16.2.1",
|
||||||
},
|
"@types/node": "^17.0.24",
|
||||||
"packageExtensions": {
|
"cross-env": "^7.0.3",
|
||||||
"httpsnippet@3.0.1": {
|
"http-server": "^14.1.1"
|
||||||
"dependencies": {
|
|
||||||
"ajv": "6.12.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,16 +17,16 @@
|
|||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.10.1",
|
"@codemirror/language": "^6.9.0",
|
||||||
"@lezer/highlight": "1.2.0",
|
"@lezer/highlight": "^1.1.6",
|
||||||
"@lezer/lr": "1.3.14"
|
"@lezer/lr": "^1.3.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lezer/generator": "1.5.1",
|
"@lezer/generator": "^1.5.0",
|
||||||
"mocha": "9.2.2",
|
"mocha": "^9.2.2",
|
||||||
"rollup": "3.29.4",
|
"rollup": "^2.70.2",
|
||||||
"rollup-plugin-dts": "6.0.2",
|
"rollup-plugin-dts": "^4.2.1",
|
||||||
"rollup-plugin-ts": "3.4.5",
|
"rollup-plugin-ts": "^2.0.7",
|
||||||
"typescript": "5.2.2"
|
"typescript": "^4.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
packages/dioc/.gitignore
vendored
Normal file
24
packages/dioc/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 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?
|
||||||
141
packages/dioc/README.md
Normal file
141
packages/dioc/README.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# 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>
|
||||||
|
```
|
||||||
2
packages/dioc/index.d.ts
vendored
Normal file
2
packages/dioc/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./dist/main.d.ts"
|
||||||
|
export * from "./dist/main.d.ts"
|
||||||
147
packages/dioc/lib/container.ts
Normal file
147
packages/dioc/lib/container.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
2
packages/dioc/lib/main.ts
Normal file
2
packages/dioc/lib/main.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./container"
|
||||||
|
export * from "./service"
|
||||||
65
packages/dioc/lib/service.ts
Normal file
65
packages/dioc/lib/service.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
33
packages/dioc/lib/testing.ts
Normal file
33
packages/dioc/lib/testing.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
34
packages/dioc/lib/vue.ts
Normal file
34
packages/dioc/lib/vue.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
54
packages/dioc/package.json
Normal file
54
packages/dioc/package.json
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
262
packages/dioc/test/container.spec.ts
Normal file
262
packages/dioc/test/container.spec.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
66
packages/dioc/test/service.spec.ts
Normal file
66
packages/dioc/test/service.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
92
packages/dioc/test/test-container.spec.ts
Normal file
92
packages/dioc/test/test-container.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
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 ?"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
2
packages/dioc/testing.d.ts
vendored
Normal file
2
packages/dioc/testing.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./dist/testing.d.ts"
|
||||||
|
export * from "./dist/testing.d.ts"
|
||||||
21
packages/dioc/tsconfig.json
Normal file
21
packages/dioc/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
16
packages/dioc/vite.config.ts
Normal file
16
packages/dioc/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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'],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
7
packages/dioc/vitest.config.ts
Normal file
7
packages/dioc/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vitest/config"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
|
||||||
|
}
|
||||||
|
})
|
||||||
2
packages/dioc/vue.d.ts
vendored
Normal file
2
packages/dioc/vue.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./dist/vue.d.ts"
|
||||||
|
export * from "./dist/vue.d.ts"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20.12.2 AS builder
|
FROM node:18.8.0 AS builder
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
:80 :3170 {
|
|
||||||
reverse_proxy localhost:8080
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"assets": [{ "include": "mailer/templates/**/*", "outDir": "dist" }],
|
"assets": [
|
||||||
|
"**/*.hbs"
|
||||||
|
],
|
||||||
"watchAssets": true
|
"watchAssets": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoppscotch-backend",
|
"name": "hoppscotch-backend",
|
||||||
"version": "2024.3.3",
|
"version": "2023.4.8",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -24,84 +24,80 @@
|
|||||||
"do-test": "pnpm run test"
|
"do-test": "pnpm run test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/server": "4.9.5",
|
"@nestjs-modules/mailer": "^1.8.1",
|
||||||
"@nestjs-modules/mailer": "1.9.1",
|
"@nestjs/apollo": "^10.1.6",
|
||||||
"@nestjs/apollo": "12.0.9",
|
"@nestjs/common": "^9.2.1",
|
||||||
"@nestjs/common": "10.2.7",
|
"@nestjs/core": "^9.2.1",
|
||||||
"@nestjs/config": "3.1.1",
|
"@nestjs/graphql": "^10.1.6",
|
||||||
"@nestjs/core": "10.2.7",
|
"@nestjs/jwt": "^10.0.1",
|
||||||
"@nestjs/graphql": "12.0.9",
|
"@nestjs/passport": "^9.0.0",
|
||||||
"@nestjs/jwt": "10.1.1",
|
"@nestjs/platform-express": "^9.2.1",
|
||||||
"@nestjs/passport": "10.0.2",
|
"@nestjs/throttler": "^4.0.0",
|
||||||
"@nestjs/platform-express": "10.2.7",
|
"@prisma/client": "^4.16.2",
|
||||||
"@nestjs/schedule": "4.0.1",
|
"apollo-server-express": "^3.11.1",
|
||||||
"@nestjs/terminus": "10.2.3",
|
"apollo-server-plugin-base": "^3.7.1",
|
||||||
"@nestjs/throttler": "5.0.1",
|
"argon2": "^0.30.3",
|
||||||
"@prisma/client": "5.8.1",
|
"bcrypt": "^5.1.0",
|
||||||
"argon2": "0.30.3",
|
"cookie": "^0.5.0",
|
||||||
"bcrypt": "5.1.0",
|
"cookie-parser": "^1.4.6",
|
||||||
"cookie": "0.5.0",
|
"express": "^4.17.1",
|
||||||
"cookie-parser": "1.4.6",
|
"express-session": "^1.17.3",
|
||||||
"cron": "3.1.6",
|
"fp-ts": "^2.13.1",
|
||||||
"express": "4.18.2",
|
"graphql": "^15.5.0",
|
||||||
"express-session": "1.17.3",
|
"graphql-query-complexity": "^0.12.0",
|
||||||
"fp-ts": "2.13.1",
|
"graphql-redis-subscriptions": "^2.5.0",
|
||||||
"graphql": "16.8.1",
|
"graphql-subscriptions": "^2.0.0",
|
||||||
"graphql-query-complexity": "0.12.0",
|
"handlebars": "^4.7.7",
|
||||||
"graphql-redis-subscriptions": "2.6.0",
|
"io-ts": "^2.2.16",
|
||||||
"graphql-subscriptions": "2.0.0",
|
"luxon": "^3.2.1",
|
||||||
"handlebars": "4.7.7",
|
"nodemailer": "^6.9.1",
|
||||||
"io-ts": "2.2.16",
|
"passport": "^0.6.0",
|
||||||
"luxon": "3.2.1",
|
"passport-github2": "^0.1.12",
|
||||||
"nodemailer": "6.9.1",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"passport": "0.6.0",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-github2": "0.1.12",
|
"passport-local": "^1.0.0",
|
||||||
"passport-google-oauth20": "2.0.0",
|
"passport-microsoft": "^1.0.0",
|
||||||
"passport-jwt": "4.0.1",
|
"prisma": "^4.16.2",
|
||||||
"passport-local": "1.0.0",
|
"reflect-metadata": "^0.1.13",
|
||||||
"passport-microsoft": "1.0.0",
|
"rimraf": "^3.0.2",
|
||||||
"posthog-node": "3.6.3",
|
"rxjs": "^7.6.0"
|
||||||
"prisma": "5.8.1",
|
|
||||||
"reflect-metadata": "0.1.13",
|
|
||||||
"rimraf": "3.0.2",
|
|
||||||
"rxjs": "7.6.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "10.2.1",
|
"@nestjs/cli": "^9.1.5",
|
||||||
"@nestjs/schematics": "10.0.3",
|
"@nestjs/schematics": "^9.0.3",
|
||||||
"@nestjs/testing": "10.2.7",
|
"@nestjs/testing": "^9.2.1",
|
||||||
"@relmify/jest-fp-ts": "2.0.2",
|
"@relmify/jest-fp-ts": "^2.0.2",
|
||||||
"@types/argon2": "0.15.0",
|
"@types/argon2": "^0.15.0",
|
||||||
"@types/bcrypt": "5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/cookie": "0.5.1",
|
"@types/cookie": "^0.5.1",
|
||||||
"@types/cookie-parser": "1.4.3",
|
"@types/cookie-parser": "^1.4.3",
|
||||||
"@types/express": "4.17.14",
|
"@types/express": "^4.17.14",
|
||||||
"@types/jest": "29.4.0",
|
"@types/jest": "^29.4.0",
|
||||||
"@types/luxon": "3.2.0",
|
"@types/luxon": "^3.2.0",
|
||||||
"@types/node": "18.11.10",
|
"@types/node": "^18.11.10",
|
||||||
"@types/nodemailer": "6.4.7",
|
"@types/nodemailer": "^6.4.7",
|
||||||
"@types/passport-github2": "1.2.5",
|
"@types/passport-github2": "^1.2.5",
|
||||||
"@types/passport-google-oauth20": "2.0.11",
|
"@types/passport-google-oauth20": "^2.0.11",
|
||||||
"@types/passport-jwt": "3.0.8",
|
"@types/passport-jwt": "^3.0.8",
|
||||||
"@types/passport-microsoft": "0.0.0",
|
"@types/passport-microsoft": "^0.0.0",
|
||||||
"@types/supertest": "2.0.12",
|
"@types/supertest": "^2.0.12",
|
||||||
"@typescript-eslint/eslint-plugin": "5.45.0",
|
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||||
"@typescript-eslint/parser": "5.45.0",
|
"@typescript-eslint/parser": "^5.45.0",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "8.29.0",
|
"eslint": "^8.29.0",
|
||||||
"eslint-config-prettier": "8.5.0",
|
"eslint-config-prettier": "^8.5.0",
|
||||||
"eslint-plugin-prettier": "4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"jest": "29.4.1",
|
"jest": "^29.4.1",
|
||||||
"jest-mock-extended": "3.0.1",
|
"jest-mock-extended": "^3.0.1",
|
||||||
"jwt": "link:@types/nestjs/jwt",
|
"jwt": "link:@types/nestjs/jwt",
|
||||||
"prettier": "2.8.4",
|
"prettier": "^2.8.4",
|
||||||
"source-map-support": "0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "6.3.2",
|
"supertest": "^6.3.2",
|
||||||
"ts-jest": "29.0.5",
|
"ts-jest": "29.0.5",
|
||||||
"ts-loader": "9.4.2",
|
"ts-loader": "^9.4.2",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"tsconfig-paths": "4.1.1",
|
"tsconfig-paths": "4.1.1",
|
||||||
"typescript": "4.9.3"
|
"typescript": "^4.9.3"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- A unique constraint covering the columns `[id]` on the table `Shortcode` will be added. If there are existing duplicate values, this will fail.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Shortcode" ADD COLUMN "embedProperties" JSONB,
|
|
||||||
ADD COLUMN "updatedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Shortcode_id_key" ON "Shortcode"("id");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "Shortcode" ADD CONSTRAINT "Shortcode_creatorUid_fkey" FOREIGN KEY ("creatorUid") REFERENCES "User"("uid") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
-- 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");
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "TeamCollection" ADD COLUMN "data" JSONB;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "UserCollection" ADD COLUMN "data" JSONB;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
|
|
||||||
@@ -41,41 +41,37 @@ model TeamInvitation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model TeamCollection {
|
model TeamCollection {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
parentID String?
|
parentID String?
|
||||||
data Json?
|
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
||||||
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
children TeamCollection[] @relation("TeamCollectionChildParent")
|
||||||
children TeamCollection[] @relation("TeamCollectionChildParent")
|
|
||||||
requests TeamRequest[]
|
requests TeamRequest[]
|
||||||
teamID String
|
teamID String
|
||||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||||
title String
|
title String
|
||||||
orderIndex Int
|
orderIndex Int
|
||||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||||
}
|
}
|
||||||
|
|
||||||
model TeamRequest {
|
model TeamRequest {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
collectionID String
|
collectionID String
|
||||||
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
||||||
teamID String
|
teamID String
|
||||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||||
title String
|
title String
|
||||||
request Json
|
request Json
|
||||||
orderIndex Int
|
orderIndex Int
|
||||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Shortcode {
|
model Shortcode {
|
||||||
id String @id @unique
|
id String @id
|
||||||
request Json
|
request Json
|
||||||
embedProperties Json?
|
creatorUid String?
|
||||||
creatorUid String?
|
createdOn DateTime @default(now())
|
||||||
User User? @relation(fields: [creatorUid], references: [uid])
|
|
||||||
createdOn DateTime @default(now())
|
|
||||||
updatedOn DateTime @default(now()) @updatedAt
|
|
||||||
|
|
||||||
@@unique(fields: [id, creatorUid], name: "creator_uid_shortcode_unique")
|
@@unique(fields: [id, creatorUid], name: "creator_uid_shortcode_unique")
|
||||||
}
|
}
|
||||||
@@ -106,7 +102,6 @@ model User {
|
|||||||
currentGQLSession Json?
|
currentGQLSession Json?
|
||||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||||
invitedUsers InvitedUsers[]
|
invitedUsers InvitedUsers[]
|
||||||
shortcodes Shortcode[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
@@ -197,7 +192,6 @@ model UserCollection {
|
|||||||
userUid String
|
userUid String
|
||||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||||
title String
|
title String
|
||||||
data Json?
|
|
||||||
orderIndex Int
|
orderIndex Int
|
||||||
type ReqType
|
type ReqType
|
||||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||||
@@ -209,12 +203,3 @@ enum TeamMemberRole {
|
|||||||
VIEWER
|
VIEWER
|
||||||
EDITOR
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
#!/usr/local/bin/node
|
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
import { spawn } from 'child_process';
|
|
||||||
import process from 'process';
|
|
||||||
|
|
||||||
function runChildProcessWithPrefix(command, args, prefix) {
|
|
||||||
const childProcess = spawn(command, args);
|
|
||||||
|
|
||||||
childProcess.stdout.on('data', (data) => {
|
|
||||||
const output = data.toString().trim().split('\n');
|
|
||||||
output.forEach((line) => {
|
|
||||||
console.log(`${prefix} | ${line}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
childProcess.stderr.on('data', (data) => {
|
|
||||||
const error = data.toString().trim().split('\n');
|
|
||||||
error.forEach((line) => {
|
|
||||||
console.error(`${prefix} | ${line}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
childProcess.on('close', (code) => {
|
|
||||||
console.log(`${prefix} Child process exited with code ${code}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
childProcess.on('error', (stuff) => {
|
|
||||||
console.error('error');
|
|
||||||
console.error(stuff);
|
|
||||||
});
|
|
||||||
|
|
||||||
return childProcess;
|
|
||||||
}
|
|
||||||
|
|
||||||
const caddyProcess = runChildProcessWithPrefix(
|
|
||||||
'caddy',
|
|
||||||
['run', '--config', '/etc/caddy/backend.Caddyfile', '--adapter', 'caddyfile'],
|
|
||||||
'App/Admin Dashboard Caddy',
|
|
||||||
);
|
|
||||||
const backendProcess = runChildProcessWithPrefix(
|
|
||||||
'pnpm',
|
|
||||||
['run', 'start:prod'],
|
|
||||||
'Backend Server',
|
|
||||||
);
|
|
||||||
|
|
||||||
caddyProcess.on('exit', (code) => {
|
|
||||||
console.log(`Exiting process because Caddy Server exited with code ${code}`);
|
|
||||||
process.exit(code);
|
|
||||||
});
|
|
||||||
|
|
||||||
backendProcess.on('exit', (code) => {
|
|
||||||
console.log(
|
|
||||||
`Exiting process because Backend Server exited with code ${code}`,
|
|
||||||
);
|
|
||||||
process.exit(code);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
console.log('SIGINT received, exiting...');
|
|
||||||
|
|
||||||
caddyProcess.kill('SIGINT');
|
|
||||||
backendProcess.kill('SIGINT');
|
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
import { ObjectType, OmitType } from '@nestjs/graphql';
|
import { ObjectType } from '@nestjs/graphql';
|
||||||
import { User } from 'src/user/user.model';
|
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
export class Admin extends OmitType(User, [
|
export class Admin {}
|
||||||
'isAdmin',
|
|
||||||
'currentRESTSession',
|
|
||||||
'currentGQLSession',
|
|
||||||
]) {}
|
|
||||||
|
|||||||
@@ -4,29 +4,26 @@ import { AdminService } from './admin.service';
|
|||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
import { PubSubModule } from '../pubsub/pubsub.module';
|
import { PubSubModule } from '../pubsub/pubsub.module';
|
||||||
import { UserModule } from '../user/user.module';
|
import { UserModule } from '../user/user.module';
|
||||||
|
import { MailerModule } from '../mailer/mailer.module';
|
||||||
import { TeamModule } from '../team/team.module';
|
import { TeamModule } from '../team/team.module';
|
||||||
import { TeamInvitationModule } from '../team-invitation/team-invitation.module';
|
import { TeamInvitationModule } from '../team-invitation/team-invitation.module';
|
||||||
import { TeamEnvironmentsModule } from '../team-environments/team-environments.module';
|
import { TeamEnvironmentsModule } from '../team-environments/team-environments.module';
|
||||||
import { TeamCollectionModule } from '../team-collection/team-collection.module';
|
import { TeamCollectionModule } from '../team-collection/team-collection.module';
|
||||||
import { TeamRequestModule } from '../team-request/team-request.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PubSubModule,
|
PubSubModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
|
MailerModule,
|
||||||
TeamModule,
|
TeamModule,
|
||||||
TeamInvitationModule,
|
TeamInvitationModule,
|
||||||
TeamEnvironmentsModule,
|
TeamEnvironmentsModule,
|
||||||
TeamCollectionModule,
|
TeamCollectionModule,
|
||||||
TeamRequestModule,
|
TeamRequestModule,
|
||||||
ShortcodeModule,
|
|
||||||
InfraConfigModule,
|
|
||||||
],
|
],
|
||||||
providers: [InfraResolver, AdminResolver, AdminService],
|
providers: [AdminResolver, AdminService],
|
||||||
exports: [AdminService],
|
exports: [AdminService],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|||||||
@@ -21,13 +21,15 @@ import { InvitedUser } from './invited-user.model';
|
|||||||
import { GqlUser } from '../decorators/gql-user.decorator';
|
import { GqlUser } from '../decorators/gql-user.decorator';
|
||||||
import { PubSubService } from '../pubsub/pubsub.service';
|
import { PubSubService } from '../pubsub/pubsub.service';
|
||||||
import { Team, TeamMember } from '../team/team.model';
|
import { Team, TeamMember } from '../team/team.model';
|
||||||
|
import { User } from '../user/user.model';
|
||||||
|
import { TeamInvitation } from '../team-invitation/team-invitation.model';
|
||||||
|
import { PaginationArgs } from '../types/input-types.args';
|
||||||
import {
|
import {
|
||||||
AddUserToTeamArgs,
|
AddUserToTeamArgs,
|
||||||
ChangeUserRoleInTeamArgs,
|
ChangeUserRoleInTeamArgs,
|
||||||
} from './input-types.args';
|
} from './input-types.args';
|
||||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
||||||
import { SkipThrottle } from '@nestjs/throttler';
|
import { SkipThrottle } from '@nestjs/throttler';
|
||||||
import { UserDeletionResult } from 'src/user/user.model';
|
|
||||||
|
|
||||||
@UseGuards(GqlThrottlerGuard)
|
@UseGuards(GqlThrottlerGuard)
|
||||||
@Resolver(() => Admin)
|
@Resolver(() => Admin)
|
||||||
@@ -47,6 +49,188 @@ export class AdminResolver {
|
|||||||
return admin;
|
return admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => [User], {
|
||||||
|
description: 'Returns a list of all admin users in infra',
|
||||||
|
})
|
||||||
|
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||||
|
async admins() {
|
||||||
|
const admins = await this.adminService.fetchAdmins();
|
||||||
|
return admins;
|
||||||
|
}
|
||||||
|
@ResolveField(() => User, {
|
||||||
|
description: 'Returns a user info by UID',
|
||||||
|
})
|
||||||
|
@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',
|
||||||
|
})
|
||||||
|
@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',
|
||||||
|
})
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
async usersCount() {
|
||||||
|
return this.adminService.getUsersCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => Number, {
|
||||||
|
description: 'Return total number of Teams in organization',
|
||||||
|
})
|
||||||
|
async teamsCount() {
|
||||||
|
return this.adminService.getTeamsCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => Number, {
|
||||||
|
description: 'Return total number of Team Collections in organization',
|
||||||
|
})
|
||||||
|
async teamCollectionsCount() {
|
||||||
|
return this.adminService.getTeamCollectionsCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => Number, {
|
||||||
|
description: 'Return total number of Team Requests in organization',
|
||||||
|
})
|
||||||
|
async teamRequestsCount() {
|
||||||
|
return this.adminService.getTeamRequestsCount();
|
||||||
|
}
|
||||||
|
|
||||||
/* Mutations */
|
/* Mutations */
|
||||||
|
|
||||||
@Mutation(() => InvitedUser, {
|
@Mutation(() => InvitedUser, {
|
||||||
@@ -70,26 +254,8 @@ export class AdminResolver {
|
|||||||
return invitedUser.right;
|
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, {
|
@Mutation(() => Boolean, {
|
||||||
description: 'Delete an user account from infra',
|
description: 'Delete an user account from infra',
|
||||||
deprecationReason: 'Use removeUsersByAdmin instead',
|
|
||||||
})
|
})
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||||
async removeUserByAdmin(
|
async removeUserByAdmin(
|
||||||
@@ -100,33 +266,12 @@ export class AdminResolver {
|
|||||||
})
|
})
|
||||||
userUID: string,
|
userUID: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const removedUser = await this.adminService.removeUserAccount(userUID);
|
const invitedUser = await this.adminService.removeUserAccount(userUID);
|
||||||
if (E.isLeft(removedUser)) throwErr(removedUser.left);
|
if (E.isLeft(invitedUser)) throwErr(invitedUser.left);
|
||||||
return removedUser.right;
|
return invitedUser.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, {
|
@Mutation(() => Boolean, {
|
||||||
description: 'Make user an admin',
|
description: 'Make user an admin',
|
||||||
deprecationReason: 'Use makeUsersAdmin instead',
|
|
||||||
})
|
})
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||||
async makeUserAdmin(
|
async makeUserAdmin(
|
||||||
@@ -142,51 +287,8 @@ export class AdminResolver {
|
|||||||
return admin.right;
|
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, {
|
@Mutation(() => Boolean, {
|
||||||
description: 'Remove user as admin',
|
description: 'Remove user as admin',
|
||||||
deprecationReason: 'Use demoteUsersByAdmin instead',
|
|
||||||
})
|
})
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||||
async removeUserAsAdmin(
|
async removeUserAsAdmin(
|
||||||
@@ -202,23 +304,6 @@ export class AdminResolver {
|
|||||||
return admin.right;
|
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, {
|
@Mutation(() => Team, {
|
||||||
description:
|
description:
|
||||||
'Create a new team by providing the user uid to nominate as Team owner',
|
'Create a new team by providing the user uid to nominate as Team owner',
|
||||||
@@ -343,23 +428,6 @@ export class AdminResolver {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean, {
|
|
||||||
description: 'Revoke Shortcode by ID',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
async revokeShortcodeByAdmin(
|
|
||||||
@Args({
|
|
||||||
name: 'code',
|
|
||||||
description: 'The shortcode to delete',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
code: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const res = await this.adminService.deleteShortcode(code);
|
|
||||||
if (E.isLeft(res)) throwErr(res.left);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Subscriptions */
|
/* Subscriptions */
|
||||||
|
|
||||||
@Subscription(() => InvitedUser, {
|
@Subscription(() => InvitedUser, {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
import { PubSubService } from '../pubsub/pubsub.service';
|
import { PubSubService } from '../pubsub/pubsub.service';
|
||||||
import { mockDeep } from 'jest-mock-extended';
|
import { mockDeep } from 'jest-mock-extended';
|
||||||
import { InvitedUsers, User as DbUser } from '@prisma/client';
|
import { InvitedUsers } from '@prisma/client';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { TeamService } from '../team/team.service';
|
import { TeamService } from '../team/team.service';
|
||||||
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
|
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
|
||||||
@@ -13,15 +13,8 @@ import { PrismaService } from 'src/prisma/prisma.service';
|
|||||||
import {
|
import {
|
||||||
DUPLICATE_EMAIL,
|
DUPLICATE_EMAIL,
|
||||||
INVALID_EMAIL,
|
INVALID_EMAIL,
|
||||||
ONLY_ONE_ADMIN_ACCOUNT,
|
|
||||||
USER_ALREADY_INVITED,
|
USER_ALREADY_INVITED,
|
||||||
USER_INVITATION_DELETION_FAILED,
|
|
||||||
USER_NOT_FOUND,
|
|
||||||
} from '../errors';
|
} 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 mockPrisma = mockDeep<PrismaService>();
|
||||||
const mockPubSub = mockDeep<PubSubService>();
|
const mockPubSub = mockDeep<PubSubService>();
|
||||||
@@ -32,8 +25,6 @@ const mockTeamRequestService = mockDeep<TeamRequestService>();
|
|||||||
const mockTeamInvitationService = mockDeep<TeamInvitationService>();
|
const mockTeamInvitationService = mockDeep<TeamInvitationService>();
|
||||||
const mockTeamCollectionService = mockDeep<TeamCollectionService>();
|
const mockTeamCollectionService = mockDeep<TeamCollectionService>();
|
||||||
const mockMailerService = mockDeep<MailerService>();
|
const mockMailerService = mockDeep<MailerService>();
|
||||||
const mockShortcodeService = mockDeep<ShortcodeService>();
|
|
||||||
const mockConfigService = mockDeep<ConfigService>();
|
|
||||||
|
|
||||||
const adminService = new AdminService(
|
const adminService = new AdminService(
|
||||||
mockUserService,
|
mockUserService,
|
||||||
@@ -45,8 +36,6 @@ const adminService = new AdminService(
|
|||||||
mockPubSub as any,
|
mockPubSub as any,
|
||||||
mockPrisma as any,
|
mockPrisma as any,
|
||||||
mockMailerService,
|
mockMailerService,
|
||||||
mockShortcodeService,
|
|
||||||
mockConfigService,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const invitedUsers: InvitedUsers[] = [
|
const invitedUsers: InvitedUsers[] = [
|
||||||
@@ -63,87 +52,20 @@ const invitedUsers: InvitedUsers[] = [
|
|||||||
invitedOn: new Date(),
|
invitedOn: new Date(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const dbAdminUsers: DbUser[] = [
|
|
||||||
{
|
|
||||||
uid: 'uid 1',
|
|
||||||
displayName: 'displayName',
|
|
||||||
email: 'email@email.com',
|
|
||||||
photoURL: 'photoURL',
|
|
||||||
isAdmin: true,
|
|
||||||
refreshToken: 'refreshToken',
|
|
||||||
currentRESTSession: '',
|
|
||||||
currentGQLSession: '',
|
|
||||||
createdOn: new Date(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: 'uid 2',
|
|
||||||
displayName: 'displayName',
|
|
||||||
email: 'email@email.com',
|
|
||||||
photoURL: 'photoURL',
|
|
||||||
isAdmin: true,
|
|
||||||
refreshToken: 'refreshToken',
|
|
||||||
currentRESTSession: '',
|
|
||||||
currentGQLSession: '',
|
|
||||||
createdOn: new Date(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const dbNonAminUser: DbUser = {
|
|
||||||
uid: 'uid 3',
|
|
||||||
displayName: 'displayName',
|
|
||||||
email: 'email@email.com',
|
|
||||||
photoURL: 'photoURL',
|
|
||||||
isAdmin: false,
|
|
||||||
refreshToken: 'refreshToken',
|
|
||||||
currentRESTSession: '',
|
|
||||||
currentGQLSession: '',
|
|
||||||
createdOn: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('AdminService', () => {
|
describe('AdminService', () => {
|
||||||
describe('fetchInvitedUsers', () => {
|
describe('fetchInvitedUsers', () => {
|
||||||
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 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],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
test('should resolve right and return an array of invited users', async () => {
|
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
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]);
|
|
||||||
// @ts-ignore
|
|
||||||
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
|
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
|
||||||
|
|
||||||
const results = await adminService.fetchInvitedUsers(paginationArgs);
|
const results = await adminService.fetchInvitedUsers();
|
||||||
expect(results).toEqual(invitedUsers);
|
expect(results).toEqual(invitedUsers);
|
||||||
});
|
});
|
||||||
test('should resolve left and return an empty array if invited users not found', async () => {
|
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([]);
|
mockPrisma.invitedUsers.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
const results = await adminService.fetchInvitedUsers(paginationArgs);
|
const results = await adminService.fetchInvitedUsers();
|
||||||
expect(results).toEqual([]);
|
expect(results).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -206,58 +128,6 @@ 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] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
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', () => {
|
describe('getUsersCount', () => {
|
||||||
test('should return count of all users in the organization', async () => {
|
test('should return count of all users in the organization', async () => {
|
||||||
mockUserService.getUsersCount.mockResolvedValueOnce(10);
|
mockUserService.getUsersCount.mockResolvedValueOnce(10);
|
||||||
|
|||||||
@@ -6,16 +6,13 @@ import * as E from 'fp-ts/Either';
|
|||||||
import * as O from 'fp-ts/Option';
|
import * as O from 'fp-ts/Option';
|
||||||
import { validateEmail } from '../utils';
|
import { validateEmail } from '../utils';
|
||||||
import {
|
import {
|
||||||
ADMIN_CAN_NOT_BE_DELETED,
|
|
||||||
DUPLICATE_EMAIL,
|
DUPLICATE_EMAIL,
|
||||||
EMAIL_FAILED,
|
EMAIL_FAILED,
|
||||||
INVALID_EMAIL,
|
INVALID_EMAIL,
|
||||||
ONLY_ONE_ADMIN_ACCOUNT,
|
ONLY_ONE_ADMIN_ACCOUNT,
|
||||||
TEAM_INVITE_ALREADY_MEMBER,
|
TEAM_INVITE_ALREADY_MEMBER,
|
||||||
TEAM_INVITE_NO_INVITE_FOUND,
|
TEAM_INVITE_NO_INVITE_FOUND,
|
||||||
USERS_NOT_FOUND,
|
|
||||||
USER_ALREADY_INVITED,
|
USER_ALREADY_INVITED,
|
||||||
USER_INVITATION_DELETION_FAILED,
|
|
||||||
USER_IS_ADMIN,
|
USER_IS_ADMIN,
|
||||||
USER_NOT_FOUND,
|
USER_NOT_FOUND,
|
||||||
} from '../errors';
|
} from '../errors';
|
||||||
@@ -27,10 +24,6 @@ import { TeamRequestService } from '../team-request/team-request.service';
|
|||||||
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
|
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
|
||||||
import { TeamInvitationService } from '../team-invitation/team-invitation.service';
|
import { TeamInvitationService } from '../team-invitation/team-invitation.service';
|
||||||
import { TeamMemberRole } from '../team/team.model';
|
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()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
@@ -44,8 +37,6 @@ export class AdminService {
|
|||||||
private readonly pubsub: PubSubService,
|
private readonly pubsub: PubSubService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly mailerService: MailerService,
|
private readonly mailerService: MailerService,
|
||||||
private readonly shortcodeService: ShortcodeService,
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,30 +44,12 @@ export class AdminService {
|
|||||||
* @param cursorID Users uid
|
* @param cursorID Users uid
|
||||||
* @param take number of users to fetch
|
* @param take number of users to fetch
|
||||||
* @returns an Either of array of user or error
|
* @returns an Either of array of user or error
|
||||||
* @deprecated use fetchUsersV2 instead
|
|
||||||
*/
|
*/
|
||||||
async fetchUsers(cursorID: string, take: number) {
|
async fetchUsers(cursorID: string, take: number) {
|
||||||
const allUsers = await this.userService.fetchAllUsers(cursorID, take);
|
const allUsers = await this.userService.fetchAllUsers(cursorID, take);
|
||||||
return allUsers;
|
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.
|
* Invite a user to join the infra.
|
||||||
* @param adminUID Admin's UID
|
* @param adminUID Admin's UID
|
||||||
@@ -101,10 +74,10 @@ export class AdminService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.mailerService.sendUserInvitationEmail(inviteeEmail, {
|
await this.mailerService.sendUserInvitationEmail(inviteeEmail, {
|
||||||
template: 'user-invitation',
|
template: 'code-your-own',
|
||||||
variables: {
|
variables: {
|
||||||
inviteeEmail: inviteeEmail,
|
inviteeEmail: inviteeEmail,
|
||||||
magicLink: `${this.configService.get('VITE_BASE_URL')}`,
|
magicLink: `${process.env.VITE_BASE_URL}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -133,68 +106,14 @@ export class AdminService {
|
|||||||
return E.right(invitedUser);
|
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[]) {
|
|
||||||
try {
|
|
||||||
await this.prisma.invitedUsers.deleteMany({
|
|
||||||
where: {
|
|
||||||
inviteeEmail: { in: inviteeEmails },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return E.right(true);
|
|
||||||
} catch (error) {
|
|
||||||
return E.left(USER_INVITATION_DELETION_FAILED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the list of invited users by the admin.
|
* Fetch the list of invited users by the admin.
|
||||||
* @returns an Either of array of `InvitedUser` object or error
|
* @returns an Either of array of `InvitedUser` object or error
|
||||||
*/
|
*/
|
||||||
async fetchInvitedUsers(paginationOption: OffsetPaginationArgs) {
|
async fetchInvitedUsers() {
|
||||||
const userEmailObjs = await this.prisma.user.findMany({
|
const invitedUsers = await this.prisma.invitedUsers.findMany();
|
||||||
select: {
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const pendingInvitedUsers = await this.prisma.invitedUsers.findMany({
|
const users: InvitedUser[] = invitedUsers.map(
|
||||||
take: paginationOption.take,
|
|
||||||
skip: paginationOption.skip,
|
|
||||||
orderBy: {
|
|
||||||
invitedOn: 'desc',
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
NOT: {
|
|
||||||
inviteeEmail: {
|
|
||||||
in: userEmailObjs.map((user) => user.email),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const users: InvitedUser[] = pendingInvitedUsers.map(
|
|
||||||
(user) => <InvitedUser>{ ...user },
|
(user) => <InvitedUser>{ ...user },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -414,7 +333,6 @@ export class AdminService {
|
|||||||
* Remove a user account by UID
|
* Remove a user account by UID
|
||||||
* @param userUid User UID
|
* @param userUid User UID
|
||||||
* @returns an Either of boolean or error
|
* @returns an Either of boolean or error
|
||||||
* @deprecated use removeUserAccounts instead
|
|
||||||
*/
|
*/
|
||||||
async removeUserAccount(userUid: string) {
|
async removeUserAccount(userUid: string) {
|
||||||
const user = await this.userService.findUserById(userUid);
|
const user = await this.userService.findUserById(userUid);
|
||||||
@@ -427,73 +345,10 @@ export class AdminService {
|
|||||||
return E.right(delUser.right);
|
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
|
* Make a user an admin
|
||||||
* @param userUid User UID
|
* @param userUid User UID
|
||||||
* @returns an Either of boolean or error
|
* @returns an Either of boolean or error
|
||||||
* @deprecated use makeUsersAdmin instead
|
|
||||||
*/
|
*/
|
||||||
async makeUserAdmin(userUID: string) {
|
async makeUserAdmin(userUID: string) {
|
||||||
const admin = await this.userService.makeAdmin(userUID);
|
const admin = await this.userService.makeAdmin(userUID);
|
||||||
@@ -501,22 +356,10 @@ export class AdminService {
|
|||||||
return E.right(true);
|
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
|
* Remove user as admin
|
||||||
* @param userUid User UID
|
* @param userUid User UID
|
||||||
* @returns an Either of boolean or error
|
* @returns an Either of boolean or error
|
||||||
* @deprecated use demoteUsersByAdmin instead
|
|
||||||
*/
|
*/
|
||||||
async removeUserAsAdmin(userUID: string) {
|
async removeUserAsAdmin(userUID: string) {
|
||||||
const adminUsers = await this.userService.fetchAdminUsers();
|
const adminUsers = await this.userService.fetchAdminUsers();
|
||||||
@@ -527,26 +370,6 @@ export class AdminService {
|
|||||||
return E.right(true);
|
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
|
* Fetch list of all the Users in org
|
||||||
* @returns number of users in the org
|
* @returns number of users in the org
|
||||||
@@ -609,35 +432,4 @@ export class AdminService {
|
|||||||
|
|
||||||
return E.right(teamInvite.right);
|
return E.right(teamInvite.right);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all created ShortCodes
|
|
||||||
*
|
|
||||||
* @param args Pagination arguments
|
|
||||||
* @param userEmail User email
|
|
||||||
* @returns ShortcodeWithUserEmail
|
|
||||||
*/
|
|
||||||
async fetchAllShortcodes(
|
|
||||||
cursorID: string,
|
|
||||||
take: number,
|
|
||||||
userEmail: string = null,
|
|
||||||
) {
|
|
||||||
return this.shortcodeService.fetchAllShortcodes(
|
|
||||||
{ cursor: cursorID, take },
|
|
||||||
userEmail,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a Shortcode
|
|
||||||
*
|
|
||||||
* @param shortcodeID ID of Shortcode being deleted
|
|
||||||
* @returns Boolean on successful deletion
|
|
||||||
*/
|
|
||||||
async deleteShortcode(shortcodeID: string) {
|
|
||||||
const result = await this.shortcodeService.deleteShortcode(shortcodeID);
|
|
||||||
|
|
||||||
if (E.isLeft(result)) return E.left(result.left);
|
|
||||||
return E.right(result.right);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Field, ObjectType } from '@nestjs/graphql';
|
|
||||||
import { Admin } from './admin.model';
|
|
||||||
|
|
||||||
@ObjectType()
|
|
||||||
export class Infra {
|
|
||||||
@Field(() => Admin, {
|
|
||||||
description: 'Admin who executed the action',
|
|
||||||
})
|
|
||||||
executedBy: Admin;
|
|
||||||
}
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
import { UseGuards } from '@nestjs/common';
|
|
||||||
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';
|
|
||||||
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
|
|
||||||
import { GqlAdminGuard } from './guards/gql-admin.guard';
|
|
||||||
import { User } from 'src/user/user.model';
|
|
||||||
import { AuthUser } from 'src/types/AuthUser';
|
|
||||||
import { throwErr } from 'src/utils';
|
|
||||||
import * as E from 'fp-ts/Either';
|
|
||||||
import { Admin } from './admin.model';
|
|
||||||
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,
|
|
||||||
private infraConfigService: InfraConfigService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Query(() => Infra, {
|
|
||||||
description: 'Fetch details of the Infrastructure',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
infra(@GqlAdmin() admin: Admin) {
|
|
||||||
const infra: Infra = { executedBy: admin };
|
|
||||||
return infra;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => [User], {
|
|
||||||
description: 'Returns a list of all admin users in infra',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
async admins() {
|
|
||||||
const admins = await this.adminService.fetchAdmins();
|
|
||||||
return admins;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => User, {
|
|
||||||
description: 'Returns a user info by UID',
|
|
||||||
})
|
|
||||||
@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 allUsersV2 instead',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
async allUsers(@Args() args: PaginationArgs): Promise<AuthUser[]> {
|
|
||||||
const users = await this.adminService.fetchUsers(args.cursor, args.take);
|
|
||||||
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(
|
|
||||||
@Args() args: OffsetPaginationArgs,
|
|
||||||
): Promise<InvitedUser[]> {
|
|
||||||
const users = await this.adminService.fetchInvitedUsers(args);
|
|
||||||
return users;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => [Team], {
|
|
||||||
description: 'Returns a list of all the teams in the infra',
|
|
||||||
})
|
|
||||||
async allTeams(@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',
|
|
||||||
})
|
|
||||||
async teamInfo(
|
|
||||||
@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',
|
|
||||||
})
|
|
||||||
async membersCountInTeam(
|
|
||||||
@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',
|
|
||||||
})
|
|
||||||
async collectionCountInTeam(
|
|
||||||
@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',
|
|
||||||
})
|
|
||||||
async requestCountInTeam(
|
|
||||||
@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',
|
|
||||||
})
|
|
||||||
async environmentCountInTeam(
|
|
||||||
@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',
|
|
||||||
})
|
|
||||||
async pendingInvitationCountInTeam(
|
|
||||||
@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',
|
|
||||||
})
|
|
||||||
async usersCount() {
|
|
||||||
return this.adminService.getUsersCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => Number, {
|
|
||||||
description: 'Return total number of Teams in organization',
|
|
||||||
})
|
|
||||||
async teamsCount() {
|
|
||||||
return this.adminService.getTeamsCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => Number, {
|
|
||||||
description: 'Return total number of Team Collections in organization',
|
|
||||||
})
|
|
||||||
async teamCollectionsCount() {
|
|
||||||
return this.adminService.getTeamCollectionsCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => Number, {
|
|
||||||
description: 'Return total number of Team Requests in organization',
|
|
||||||
})
|
|
||||||
async teamRequestsCount() {
|
|
||||||
return this.adminService.getTeamRequestsCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => [ShortcodeWithUserEmail], {
|
|
||||||
description: 'Returns a list of all the shortcodes in the infra',
|
|
||||||
})
|
|
||||||
async allShortcodes(
|
|
||||||
@Args() args: PaginationArgs,
|
|
||||||
@Args({
|
|
||||||
name: 'userEmail',
|
|
||||||
nullable: true,
|
|
||||||
description: 'Users email to filter shortcodes by',
|
|
||||||
})
|
|
||||||
userEmail: string,
|
|
||||||
) {
|
|
||||||
return await this.adminService.fetchAllShortcodes(
|
|
||||||
args.cursor,
|
|
||||||
args.take,
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,73 +19,55 @@ import { UserCollectionModule } from './user-collection/user-collection.module';
|
|||||||
import { ShortcodeModule } from './shortcode/shortcode.module';
|
import { ShortcodeModule } from './shortcode/shortcode.module';
|
||||||
import { COOKIES_NOT_FOUND } from './errors';
|
import { COOKIES_NOT_FOUND } from './errors';
|
||||||
import { ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app/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';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
GraphQLModule.forRoot<ApolloDriverConfig>({
|
||||||
isGlobal: true,
|
buildSchemaOptions: {
|
||||||
load: [async () => loadInfraConfiguration()],
|
numberScalarMode: 'integer',
|
||||||
}),
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
}),
|
cors: {
|
||||||
ThrottlerModule.forRootAsync({
|
origin: process.env.WHITELISTED_ORIGINS.split(','),
|
||||||
imports: [ConfigModule],
|
credentials: true,
|
||||||
inject: [ConfigService],
|
},
|
||||||
useFactory: async (configService: ConfigService) => [
|
playground: process.env.PRODUCTION !== 'true',
|
||||||
{
|
debug: process.env.PRODUCTION !== 'true',
|
||||||
ttl: +configService.get('RATE_LIMIT_TTL'),
|
autoSchemaFile: true,
|
||||||
limit: +configService.get('RATE_LIMIT_MAX'),
|
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,
|
||||||
|
}),
|
||||||
|
ThrottlerModule.forRoot({
|
||||||
|
ttl: +process.env.RATE_LIMIT_TTL,
|
||||||
|
limit: +process.env.RATE_LIMIT_MAX,
|
||||||
}),
|
}),
|
||||||
MailerModule.register(),
|
|
||||||
UserModule,
|
UserModule,
|
||||||
AuthModule.register(),
|
AuthModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
UserSettingsModule,
|
UserSettingsModule,
|
||||||
UserEnvironmentsModule,
|
UserEnvironmentsModule,
|
||||||
@@ -98,10 +80,6 @@ import { HealthModule } from './health/health.module';
|
|||||||
TeamInvitationModule,
|
TeamInvitationModule,
|
||||||
UserCollectionModule,
|
UserCollectionModule,
|
||||||
ShortcodeModule,
|
ShortcodeModule,
|
||||||
InfraConfigModule,
|
|
||||||
PosthogModule,
|
|
||||||
ScheduleModule.forRoot(),
|
|
||||||
HealthModule,
|
|
||||||
],
|
],
|
||||||
providers: [GQLComplexityPlugin],
|
providers: [GQLComplexityPlugin],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
InternalServerErrorException,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Request,
|
Request,
|
||||||
@@ -18,29 +19,23 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
|||||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||||
import { AuthUser } from 'src/types/AuthUser';
|
import { AuthUser } from 'src/types/AuthUser';
|
||||||
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
|
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
|
||||||
import { AuthProvider, authCookieHandler, authProviderCheck } from './helper';
|
import {
|
||||||
|
AuthProvider,
|
||||||
|
authCookieHandler,
|
||||||
|
authProviderCheck,
|
||||||
|
throwHTTPErr,
|
||||||
|
} from './helper';
|
||||||
import { GoogleSSOGuard } from './guards/google-sso.guard';
|
import { GoogleSSOGuard } from './guards/google-sso.guard';
|
||||||
import { GithubSSOGuard } from './guards/github-sso.guard';
|
import { GithubSSOGuard } from './guards/github-sso.guard';
|
||||||
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
|
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
|
||||||
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
||||||
import { SkipThrottle } from '@nestjs/throttler';
|
import { SkipThrottle } from '@nestjs/throttler';
|
||||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { throwHTTPErr } from 'src/utils';
|
|
||||||
|
|
||||||
@UseGuards(ThrottlerBehindProxyGuard)
|
@UseGuards(ThrottlerBehindProxyGuard)
|
||||||
@Controller({ path: 'auth', version: '1' })
|
@Controller({ path: 'auth', version: '1' })
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(
|
constructor(private authService: AuthService) {}
|
||||||
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
|
** Route to initiate magic-link auth for a users email
|
||||||
@@ -50,14 +45,8 @@ export class AuthController {
|
|||||||
@Body() authData: SignInMagicDto,
|
@Body() authData: SignInMagicDto,
|
||||||
@Query('origin') origin: string,
|
@Query('origin') origin: string,
|
||||||
) {
|
) {
|
||||||
if (
|
if (!authProviderCheck(AuthProvider.EMAIL))
|
||||||
!authProviderCheck(
|
|
||||||
AuthProvider.EMAIL,
|
|
||||||
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||||
}
|
|
||||||
|
|
||||||
const deviceIdToken = await this.authService.signInMagicLink(
|
const deviceIdToken = await this.authService.signInMagicLink(
|
||||||
authData.email,
|
authData.email,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { UserModule } from 'src/user/user.module';
|
import { UserModule } from 'src/user/user.module';
|
||||||
|
import { MailerModule } from 'src/mailer/mailer.module';
|
||||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
@@ -11,55 +12,25 @@ import { GoogleStrategy } from './strategies/google.strategy';
|
|||||||
import { GithubStrategy } from './strategies/github.strategy';
|
import { GithubStrategy } from './strategies/github.strategy';
|
||||||
import { MicrosoftStrategy } from './strategies/microsoft.strategy';
|
import { MicrosoftStrategy } from './strategies/microsoft.strategy';
|
||||||
import { AuthProvider, authProviderCheck } from './helper';
|
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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
|
MailerModule,
|
||||||
PassportModule,
|
PassportModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.register({
|
||||||
imports: [ConfigModule],
|
secret: process.env.JWT_SECRET,
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: async (configService: ConfigService) => ({
|
|
||||||
secret: configService.get('JWT_SECRET'),
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
InfraConfigModule,
|
|
||||||
],
|
],
|
||||||
providers: [AuthService, JwtStrategy, RTJwtStrategy],
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
JwtStrategy,
|
||||||
|
RTJwtStrategy,
|
||||||
|
...(authProviderCheck(AuthProvider.GOOGLE) ? [GoogleStrategy] : []),
|
||||||
|
...(authProviderCheck(AuthProvider.GITHUB) ? [GithubStrategy] : []),
|
||||||
|
...(authProviderCheck(AuthProvider.MICROSOFT) ? [MicrosoftStrategy] : []),
|
||||||
|
],
|
||||||
controllers: [AuthController],
|
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,26 +21,15 @@ import { VerifyMagicDto } from './dto/verify-magic.dto';
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import * as E from 'fp-ts/Either';
|
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 mockPrisma = mockDeep<PrismaService>();
|
||||||
const mockUser = mockDeep<UserService>();
|
const mockUser = mockDeep<UserService>();
|
||||||
const mockJWT = mockDeep<JwtService>();
|
const mockJWT = mockDeep<JwtService>();
|
||||||
const mockMailer = mockDeep<MailerService>();
|
const mockMailer = mockDeep<MailerService>();
|
||||||
const mockConfigService = mockDeep<ConfigService>();
|
|
||||||
const mockInfraConfigService = mockDeep<InfraConfigService>();
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const authService = new AuthService(
|
const authService = new AuthService(mockUser, mockPrisma, mockJWT, mockMailer);
|
||||||
mockUser,
|
|
||||||
mockPrisma,
|
|
||||||
mockJWT,
|
|
||||||
mockMailer,
|
|
||||||
mockConfigService,
|
|
||||||
mockInfraConfigService,
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentTime = new Date();
|
const currentTime = new Date();
|
||||||
|
|
||||||
@@ -102,8 +91,6 @@ describe('signInMagicLink', () => {
|
|||||||
mockUser.createUserViaMagicLink.mockResolvedValue(user);
|
mockUser.createUserViaMagicLink.mockResolvedValue(user);
|
||||||
// create new entry in VerificationToken table
|
// create new entry in VerificationToken table
|
||||||
mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData);
|
mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData);
|
||||||
// Read env variable 'MAGIC_LINK_TOKEN_VALIDITY' from config service
|
|
||||||
mockConfigService.get.mockReturnValue('3');
|
|
||||||
|
|
||||||
const result = await authService.signInMagicLink(
|
const result = await authService.signInMagicLink(
|
||||||
'dwight@dundermifflin.com',
|
'dwight@dundermifflin.com',
|
||||||
|
|||||||
@@ -24,12 +24,10 @@ import {
|
|||||||
RefreshTokenPayload,
|
RefreshTokenPayload,
|
||||||
} from 'src/types/AuthTokens';
|
} from 'src/types/AuthTokens';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { RESTError } from 'src/types/RESTError';
|
import { AuthError } from 'src/types/AuthError';
|
||||||
import { AuthUser, IsAdmin } from 'src/types/AuthUser';
|
import { AuthUser, IsAdmin } from 'src/types/AuthUser';
|
||||||
import { VerificationToken } from '@prisma/client';
|
import { VerificationToken } from '@prisma/client';
|
||||||
import { Origin } from './helper';
|
import { Origin } from './helper';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { InfraConfigService } from 'src/infra-config/infra-config.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -38,8 +36,6 @@ export class AuthService {
|
|||||||
private prismaService: PrismaService,
|
private prismaService: PrismaService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private readonly mailerService: MailerService,
|
private readonly mailerService: MailerService,
|
||||||
private readonly configService: ConfigService,
|
|
||||||
private infraConfigService: InfraConfigService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,12 +46,10 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
private async generateMagicLinkTokens(user: AuthUser) {
|
private async generateMagicLinkTokens(user: AuthUser) {
|
||||||
const salt = await bcrypt.genSalt(
|
const salt = await bcrypt.genSalt(
|
||||||
parseInt(this.configService.get('TOKEN_SALT_COMPLEXITY')),
|
parseInt(process.env.TOKEN_SALT_COMPLEXITY),
|
||||||
);
|
);
|
||||||
const expiresOn = DateTime.now()
|
const expiresOn = DateTime.now()
|
||||||
.plus({
|
.plus({ hours: parseInt(process.env.MAGIC_LINK_TOKEN_VALIDITY) })
|
||||||
hours: parseInt(this.configService.get('MAGIC_LINK_TOKEN_VALIDITY')),
|
|
||||||
})
|
|
||||||
.toISO()
|
.toISO()
|
||||||
.toString();
|
.toString();
|
||||||
|
|
||||||
@@ -101,13 +95,13 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
private async generateRefreshToken(userUid: string) {
|
private async generateRefreshToken(userUid: string) {
|
||||||
const refreshTokenPayload: RefreshTokenPayload = {
|
const refreshTokenPayload: RefreshTokenPayload = {
|
||||||
iss: this.configService.get('VITE_BASE_URL'),
|
iss: process.env.VITE_BASE_URL,
|
||||||
sub: userUid,
|
sub: userUid,
|
||||||
aud: [this.configService.get('VITE_BASE_URL')],
|
aud: [process.env.VITE_BASE_URL],
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshToken = await this.jwtService.sign(refreshTokenPayload, {
|
const refreshToken = await this.jwtService.sign(refreshTokenPayload, {
|
||||||
expiresIn: this.configService.get('REFRESH_TOKEN_VALIDITY'), //7 Days
|
expiresIn: process.env.REFRESH_TOKEN_VALIDITY, //7 Days
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshTokenHash = await argon2.hash(refreshToken);
|
const refreshTokenHash = await argon2.hash(refreshToken);
|
||||||
@@ -117,7 +111,7 @@ export class AuthService {
|
|||||||
userUid,
|
userUid,
|
||||||
);
|
);
|
||||||
if (E.isLeft(updatedUser))
|
if (E.isLeft(updatedUser))
|
||||||
return E.left(<RESTError>{
|
return E.left(<AuthError>{
|
||||||
message: updatedUser.left,
|
message: updatedUser.left,
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
statusCode: HttpStatus.NOT_FOUND,
|
||||||
});
|
});
|
||||||
@@ -133,9 +127,9 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
async generateAuthTokens(userUid: string) {
|
async generateAuthTokens(userUid: string) {
|
||||||
const accessTokenPayload: AccessTokenPayload = {
|
const accessTokenPayload: AccessTokenPayload = {
|
||||||
iss: this.configService.get('VITE_BASE_URL'),
|
iss: process.env.VITE_BASE_URL,
|
||||||
sub: userUid,
|
sub: userUid,
|
||||||
aud: [this.configService.get('VITE_BASE_URL')],
|
aud: [process.env.VITE_BASE_URL],
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshToken = await this.generateRefreshToken(userUid);
|
const refreshToken = await this.generateRefreshToken(userUid);
|
||||||
@@ -143,7 +137,7 @@ export class AuthService {
|
|||||||
|
|
||||||
return E.right(<AuthTokens>{
|
return E.right(<AuthTokens>{
|
||||||
access_token: await this.jwtService.sign(accessTokenPayload, {
|
access_token: await this.jwtService.sign(accessTokenPayload, {
|
||||||
expiresIn: this.configService.get('ACCESS_TOKEN_VALIDITY'), //1 Day
|
expiresIn: process.env.ACCESS_TOKEN_VALIDITY, //1 Day
|
||||||
}),
|
}),
|
||||||
refresh_token: refreshToken.right,
|
refresh_token: refreshToken.right,
|
||||||
});
|
});
|
||||||
@@ -224,18 +218,18 @@ export class AuthService {
|
|||||||
let url: string;
|
let url: string;
|
||||||
switch (origin) {
|
switch (origin) {
|
||||||
case Origin.ADMIN:
|
case Origin.ADMIN:
|
||||||
url = this.configService.get('VITE_ADMIN_URL');
|
url = process.env.VITE_ADMIN_URL;
|
||||||
break;
|
break;
|
||||||
case Origin.APP:
|
case Origin.APP:
|
||||||
url = this.configService.get('VITE_BASE_URL');
|
url = process.env.VITE_BASE_URL;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// if origin is invalid by default set URL to Hoppscotch-App
|
// if origin is invalid by default set URL to Hoppscotch-App
|
||||||
url = this.configService.get('VITE_BASE_URL');
|
url = process.env.VITE_BASE_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.mailerService.sendEmail(email, {
|
await this.mailerService.sendEmail(email, {
|
||||||
template: 'user-invitation',
|
template: 'code-your-own',
|
||||||
variables: {
|
variables: {
|
||||||
inviteeEmail: email,
|
inviteeEmail: email,
|
||||||
magicLink: `${url}/enter?token=${generatedTokens.token}`,
|
magicLink: `${url}/enter?token=${generatedTokens.token}`,
|
||||||
@@ -255,7 +249,7 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
async verifyMagicLinkTokens(
|
async verifyMagicLinkTokens(
|
||||||
magicLinkIDTokens: VerifyMagicDto,
|
magicLinkIDTokens: VerifyMagicDto,
|
||||||
): Promise<E.Right<AuthTokens> | E.Left<RESTError>> {
|
): Promise<E.Right<AuthTokens> | E.Left<AuthError>> {
|
||||||
const passwordlessTokens = await this.validatePasswordlessTokens(
|
const passwordlessTokens = await this.validatePasswordlessTokens(
|
||||||
magicLinkIDTokens,
|
magicLinkIDTokens,
|
||||||
);
|
);
|
||||||
@@ -373,7 +367,7 @@ export class AuthService {
|
|||||||
if (usersCount === 1) {
|
if (usersCount === 1) {
|
||||||
const elevatedUser = await this.usersService.makeAdmin(user.uid);
|
const elevatedUser = await this.usersService.makeAdmin(user.uid);
|
||||||
if (E.isLeft(elevatedUser))
|
if (E.isLeft(elevatedUser))
|
||||||
return E.left(<RESTError>{
|
return E.left(<AuthError>{
|
||||||
message: elevatedUser.left,
|
message: elevatedUser.left,
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
statusCode: HttpStatus.NOT_FOUND,
|
||||||
});
|
});
|
||||||
@@ -383,8 +377,4 @@ export class AuthService {
|
|||||||
|
|
||||||
return E.right(<IsAdmin>{ isAdmin: false });
|
return E.right(<IsAdmin>{ isAdmin: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
getAuthProviders() {
|
|
||||||
return this.infraConfigService.getAllowedAuthProviders();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,16 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { AuthProvider, authProviderCheck } from '../helper';
|
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { throwHTTPErr } from 'src/utils';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {
|
export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {
|
||||||
constructor(private readonly configService: ConfigService) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
canActivate(
|
canActivate(
|
||||||
context: ExecutionContext,
|
context: ExecutionContext,
|
||||||
): boolean | Promise<boolean> | Observable<boolean> {
|
): boolean | Promise<boolean> | Observable<boolean> {
|
||||||
if (
|
if (!authProviderCheck(AuthProvider.GITHUB))
|
||||||
!authProviderCheck(
|
|
||||||
AuthProvider.GITHUB,
|
|
||||||
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||||
}
|
|
||||||
|
|
||||||
return super.canActivate(context);
|
return super.canActivate(context);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,16 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { AuthProvider, authProviderCheck } from '../helper';
|
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { throwHTTPErr } from 'src/utils';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {
|
export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {
|
||||||
constructor(private readonly configService: ConfigService) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
canActivate(
|
canActivate(
|
||||||
context: ExecutionContext,
|
context: ExecutionContext,
|
||||||
): boolean | Promise<boolean> | Observable<boolean> {
|
): boolean | Promise<boolean> | Observable<boolean> {
|
||||||
if (
|
if (!authProviderCheck(AuthProvider.GOOGLE))
|
||||||
!authProviderCheck(
|
|
||||||
AuthProvider.GOOGLE,
|
|
||||||
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||||
}
|
|
||||||
|
|
||||||
return super.canActivate(context);
|
return super.canActivate(context);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,22 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { AuthProvider, authProviderCheck } from '../helper';
|
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { throwHTTPErr } from 'src/utils';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MicrosoftSSOGuard
|
export class MicrosoftSSOGuard
|
||||||
extends AuthGuard('microsoft')
|
extends AuthGuard('microsoft')
|
||||||
implements CanActivate
|
implements CanActivate
|
||||||
{
|
{
|
||||||
constructor(private readonly configService: ConfigService) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
canActivate(
|
canActivate(
|
||||||
context: ExecutionContext,
|
context: ExecutionContext,
|
||||||
): boolean | Promise<boolean> | Observable<boolean> {
|
): boolean | Promise<boolean> | Observable<boolean> {
|
||||||
if (
|
if (!authProviderCheck(AuthProvider.MICROSOFT))
|
||||||
!authProviderCheck(
|
|
||||||
AuthProvider.MICROSOFT,
|
|
||||||
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throwHTTPErr({
|
throwHTTPErr({
|
||||||
message: AUTH_PROVIDER_NOT_SPECIFIED,
|
message: AUTH_PROVIDER_NOT_SPECIFIED,
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return super.canActivate(context);
|
return super.canActivate(context);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
import { AuthError } from 'src/types/AuthError';
|
||||||
import { AuthTokens } from 'src/types/AuthTokens';
|
import { AuthTokens } from 'src/types/AuthTokens';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import * as cookie from 'cookie';
|
import * as cookie from 'cookie';
|
||||||
import { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors';
|
import { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors';
|
||||||
import { throwErr } from 'src/utils';
|
import { throwErr } from 'src/utils';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
|
|
||||||
enum AuthTokenType {
|
enum AuthTokenType {
|
||||||
ACCESS_TOKEN = 'access_token',
|
ACCESS_TOKEN = 'access_token',
|
||||||
@@ -24,6 +24,15 @@ export enum AuthProvider {
|
|||||||
EMAIL = 'EMAIL',
|
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
|
* Sets and returns the cookies in the response object on successful authentication
|
||||||
* @param res Express Response Object
|
* @param res Express Response Object
|
||||||
@@ -36,17 +45,15 @@ export const authCookieHandler = (
|
|||||||
redirect: boolean,
|
redirect: boolean,
|
||||||
redirectUrl: string | null,
|
redirectUrl: string | null,
|
||||||
) => {
|
) => {
|
||||||
const configService = new ConfigService();
|
|
||||||
|
|
||||||
const currentTime = DateTime.now();
|
const currentTime = DateTime.now();
|
||||||
const accessTokenValidity = currentTime
|
const accessTokenValidity = currentTime
|
||||||
.plus({
|
.plus({
|
||||||
milliseconds: parseInt(configService.get('ACCESS_TOKEN_VALIDITY')),
|
milliseconds: parseInt(process.env.ACCESS_TOKEN_VALIDITY),
|
||||||
})
|
})
|
||||||
.toMillis();
|
.toMillis();
|
||||||
const refreshTokenValidity = currentTime
|
const refreshTokenValidity = currentTime
|
||||||
.plus({
|
.plus({
|
||||||
milliseconds: parseInt(configService.get('REFRESH_TOKEN_VALIDITY')),
|
milliseconds: parseInt(process.env.REFRESH_TOKEN_VALIDITY),
|
||||||
})
|
})
|
||||||
.toMillis();
|
.toMillis();
|
||||||
|
|
||||||
@@ -68,12 +75,10 @@ export const authCookieHandler = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// check to see if redirectUrl is a whitelisted url
|
// check to see if redirectUrl is a whitelisted url
|
||||||
const whitelistedOrigins = configService
|
const whitelistedOrigins = process.env.WHITELISTED_ORIGINS.split(',');
|
||||||
.get('WHITELISTED_ORIGINS')
|
|
||||||
.split(',');
|
|
||||||
if (!whitelistedOrigins.includes(redirectUrl))
|
if (!whitelistedOrigins.includes(redirectUrl))
|
||||||
// if it is not redirect by default to REDIRECT_URL
|
// if it is not redirect by default to REDIRECT_URL
|
||||||
redirectUrl = configService.get('REDIRECT_URL');
|
redirectUrl = process.env.REDIRECT_URL;
|
||||||
|
|
||||||
return res.status(HttpStatus.OK).redirect(redirectUrl);
|
return res.status(HttpStatus.OK).redirect(redirectUrl);
|
||||||
};
|
};
|
||||||
@@ -107,16 +112,13 @@ export const subscriptionContextCookieParser = (rawCookies: string) => {
|
|||||||
* @param provider Provider we want to check the presence of
|
* @param provider Provider we want to check the presence of
|
||||||
* @returns Boolean if provider specified is present or not
|
* @returns Boolean if provider specified is present or not
|
||||||
*/
|
*/
|
||||||
export function authProviderCheck(
|
export function authProviderCheck(provider: string) {
|
||||||
provider: string,
|
|
||||||
VITE_ALLOWED_AUTH_PROVIDERS: string,
|
|
||||||
) {
|
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
throwErr(AUTH_PROVIDER_NOT_SPECIFIED);
|
throwErr(AUTH_PROVIDER_NOT_SPECIFIED);
|
||||||
}
|
}
|
||||||
|
|
||||||
const envVariables = VITE_ALLOWED_AUTH_PROVIDERS
|
const envVariables = process.env.VITE_ALLOWED_AUTH_PROVIDERS
|
||||||
? VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
|
? process.env.VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
|
||||||
provider.trim().toUpperCase(),
|
provider.trim().toUpperCase(),
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@@ -5,20 +5,18 @@ import { AuthService } from '../auth.service';
|
|||||||
import { UserService } from 'src/user/user.service';
|
import { UserService } from 'src/user/user.service';
|
||||||
import * as O from 'fp-ts/Option';
|
import * as O from 'fp-ts/Option';
|
||||||
import * as E from 'fp-ts/Either';
|
import * as E from 'fp-ts/Either';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GithubStrategy extends PassportStrategy(Strategy) {
|
export class GithubStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(
|
constructor(
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private usersService: UserService,
|
private usersService: UserService,
|
||||||
private configService: ConfigService,
|
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
clientID: configService.get('INFRA.GITHUB_CLIENT_ID'),
|
clientID: process.env.GITHUB_CLIENT_ID,
|
||||||
clientSecret: configService.get('INFRA.GITHUB_CLIENT_SECRET'),
|
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||||
callbackURL: configService.get('INFRA.GITHUB_CALLBACK_URL'),
|
callbackURL: process.env.GITHUB_CALLBACK_URL,
|
||||||
scope: [configService.get('INFRA.GITHUB_SCOPE')],
|
scope: [process.env.GITHUB_SCOPE],
|
||||||
store: true,
|
store: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,18 @@ import { UserService } from 'src/user/user.service';
|
|||||||
import * as O from 'fp-ts/Option';
|
import * as O from 'fp-ts/Option';
|
||||||
import { AuthService } from '../auth.service';
|
import { AuthService } from '../auth.service';
|
||||||
import * as E from 'fp-ts/Either';
|
import * as E from 'fp-ts/Either';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GoogleStrategy extends PassportStrategy(Strategy) {
|
export class GoogleStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(
|
constructor(
|
||||||
private usersService: UserService,
|
private usersService: UserService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private configService: ConfigService,
|
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
clientID: configService.get('INFRA.GOOGLE_CLIENT_ID'),
|
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||||
clientSecret: configService.get('INFRA.GOOGLE_CLIENT_SECRET'),
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
callbackURL: configService.get('INFRA.GOOGLE_CALLBACK_URL'),
|
callbackURL: process.env.GOOGLE_CALLBACK_URL,
|
||||||
scope: configService.get('INFRA.GOOGLE_SCOPE').split(','),
|
scope: process.env.GOOGLE_SCOPE.split(','),
|
||||||
passReqToCallback: true,
|
passReqToCallback: true,
|
||||||
store: true,
|
store: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,14 +15,10 @@ import {
|
|||||||
INVALID_ACCESS_TOKEN,
|
INVALID_ACCESS_TOKEN,
|
||||||
USER_NOT_FOUND,
|
USER_NOT_FOUND,
|
||||||
} from 'src/errors';
|
} from 'src/errors';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
constructor(
|
constructor(private usersService: UserService) {
|
||||||
private usersService: UserService,
|
|
||||||
private configService: ConfigService,
|
|
||||||
) {
|
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||||
(request: Request) => {
|
(request: Request) => {
|
||||||
@@ -33,7 +29,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
return ATCookie;
|
return ATCookie;
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
secretOrKey: configService.get('JWT_SECRET'),
|
secretOrKey: process.env.JWT_SECRET,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,21 +5,19 @@ import { AuthService } from '../auth.service';
|
|||||||
import { UserService } from 'src/user/user.service';
|
import { UserService } from 'src/user/user.service';
|
||||||
import * as O from 'fp-ts/Option';
|
import * as O from 'fp-ts/Option';
|
||||||
import * as E from 'fp-ts/Either';
|
import * as E from 'fp-ts/Either';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MicrosoftStrategy extends PassportStrategy(Strategy) {
|
export class MicrosoftStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(
|
constructor(
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private usersService: UserService,
|
private usersService: UserService,
|
||||||
private configService: ConfigService,
|
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
clientID: configService.get('INFRA.MICROSOFT_CLIENT_ID'),
|
clientID: process.env.MICROSOFT_CLIENT_ID,
|
||||||
clientSecret: configService.get('INFRA.MICROSOFT_CLIENT_SECRET'),
|
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
|
||||||
callbackURL: configService.get('INFRA.MICROSOFT_CALLBACK_URL'),
|
callbackURL: process.env.MICROSOFT_CALLBACK_URL,
|
||||||
scope: [configService.get('INFRA.MICROSOFT_SCOPE')],
|
scope: [process.env.MICROSOFT_SCOPE],
|
||||||
tenant: configService.get('INFRA.MICROSOFT_TENANT'),
|
tenant: process.env.MICROSOFT_TENANT,
|
||||||
store: true,
|
store: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,10 @@ import {
|
|||||||
USER_NOT_FOUND,
|
USER_NOT_FOUND,
|
||||||
} from 'src/errors';
|
} from 'src/errors';
|
||||||
import * as O from 'fp-ts/Option';
|
import * as O from 'fp-ts/Option';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
|
export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
|
||||||
constructor(
|
constructor(private usersService: UserService) {
|
||||||
private usersService: UserService,
|
|
||||||
private configService: ConfigService,
|
|
||||||
) {
|
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||||
(request: Request) => {
|
(request: Request) => {
|
||||||
@@ -32,7 +28,7 @@ export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
|
|||||||
return RTCookie;
|
return RTCookie;
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
secretOrKey: configService.get('JWT_SECRET'),
|
secretOrKey: process.env.JWT_SECRET,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,6 @@ export const DUPLICATE_EMAIL = 'email/both_emails_cannot_be_same' as const;
|
|||||||
export const ONLY_ONE_ADMIN_ACCOUNT =
|
export const ONLY_ONE_ADMIN_ACCOUNT =
|
||||||
'admin/only_one_admin_account_found' as const;
|
'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)
|
* Token Authorization failed (Check 'Authorization' Header)
|
||||||
* (GqlAuthGuard)
|
* (GqlAuthGuard)
|
||||||
@@ -36,13 +28,6 @@ export const JSON_INVALID = 'json_invalid';
|
|||||||
*/
|
*/
|
||||||
export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified';
|
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
|
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file
|
||||||
*/
|
*/
|
||||||
@@ -84,12 +69,6 @@ export const USER_ALREADY_INVITED = 'admin/user_already_invited' as const;
|
|||||||
*/
|
*/
|
||||||
export const USER_UPDATE_FAILED = 'user/update_failed' 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
|
* User deletion failure
|
||||||
* (UserService)
|
* (UserService)
|
||||||
@@ -113,13 +92,6 @@ export const USER_IS_OWNER = 'user/is_owner' as const;
|
|||||||
*/
|
*/
|
||||||
export const USER_IS_ADMIN = 'user/is_admin' 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
|
* Teams not found
|
||||||
* (TeamsService)
|
* (TeamsService)
|
||||||
@@ -234,12 +206,6 @@ export const TEAM_COL_NOT_SAME_PARENT =
|
|||||||
export const TEAM_COL_SAME_NEXT_COLL =
|
export const TEAM_COL_SAME_NEXT_COLL =
|
||||||
'team_coll/collection_and_next_collection_are_same';
|
'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
|
* Team Collection Re-Ordering Failed
|
||||||
* (TeamCollectionService)
|
* (TeamCollectionService)
|
||||||
@@ -288,20 +254,6 @@ export const TEAM_COLL_INVALID_JSON = 'team_coll/invalid_json';
|
|||||||
*/
|
*/
|
||||||
export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const;
|
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
|
* Tried to perform an action on a request that doesn't accept their member role level
|
||||||
* (GqlRequestTeamMemberGuard)
|
* (GqlRequestTeamMemberGuard)
|
||||||
@@ -327,19 +279,6 @@ export const TEAM_REQ_INVALID_TARGET_COLL_ID =
|
|||||||
*/
|
*/
|
||||||
export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const;
|
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
|
* No Postmark Sender Email defined
|
||||||
* (AuthService)
|
* (AuthService)
|
||||||
@@ -379,6 +318,18 @@ export const TEAM_INVITATION_NOT_FOUND =
|
|||||||
*/
|
*/
|
||||||
export const SHORTCODE_NOT_FOUND = 'shortcode/not_found' as const;
|
export const SHORTCODE_NOT_FOUND = 'shortcode/not_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalid ShortCode format
|
||||||
|
* (ShortcodeService)
|
||||||
|
*/
|
||||||
|
export const SHORTCODE_INVALID_JSON = 'shortcode/invalid_json' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShortCode already exists in DB
|
||||||
|
* (ShortcodeService)
|
||||||
|
*/
|
||||||
|
export const SHORTCODE_ALREADY_EXISTS = 'shortcode/already_exists' as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalid or non-existent TEAM ENVIRONMENT ID
|
* Invalid or non-existent TEAM ENVIRONMENT ID
|
||||||
* (TeamEnvironmentsService)
|
* (TeamEnvironmentsService)
|
||||||
@@ -646,13 +597,6 @@ export const USER_COLL_REORDERING_FAILED =
|
|||||||
export const USER_COLL_SAME_NEXT_COLL =
|
export const USER_COLL_SAME_NEXT_COLL =
|
||||||
'user_coll/user_collection_and_next_user_collection_are_same' as const;
|
'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
|
* The User Collection does not belong to the logged-in user
|
||||||
* (UserCollectionService)
|
* (UserCollectionService)
|
||||||
@@ -677,87 +621,3 @@ export const MAILER_SMTP_URL_UNDEFINED = 'mailer/smtp_url_undefined' as const;
|
|||||||
*/
|
*/
|
||||||
export const MAILER_FROM_ADDRESS_UNDEFINED =
|
export const MAILER_FROM_ADDRESS_UNDEFINED =
|
||||||
'mailer/from_address_undefined' as const;
|
'mailer/from_address_undefined' as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* SharedRequest invalid request JSON format
|
|
||||||
* (ShortcodeService)
|
|
||||||
*/
|
|
||||||
export const SHORTCODE_INVALID_REQUEST_JSON =
|
|
||||||
'shortcode/request_invalid_format' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SharedRequest invalid properties JSON format
|
|
||||||
* (ShortcodeService)
|
|
||||||
*/
|
|
||||||
export const SHORTCODE_INVALID_PROPERTIES_JSON =
|
|
||||||
'shortcode/properties_invalid_format' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SharedRequest invalid properties not found
|
|
||||||
* (ShortcodeService)
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import { UserRequestUserCollectionResolver } from './user-request/resolvers/user
|
|||||||
import { UserEnvsUserResolver } from './user-environment/user.resolver';
|
import { UserEnvsUserResolver } from './user-environment/user.resolver';
|
||||||
import { UserHistoryUserResolver } from './user-history/user.resolver';
|
import { UserHistoryUserResolver } from './user-history/user.resolver';
|
||||||
import { UserSettingsUserResolver } from './user-settings/user.resolver';
|
import { UserSettingsUserResolver } from './user-settings/user.resolver';
|
||||||
import { InfraResolver } from './admin/infra.resolver';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All the resolvers present in the application.
|
* All the resolvers present in the application.
|
||||||
@@ -35,7 +34,6 @@ import { InfraResolver } from './admin/infra.resolver';
|
|||||||
* NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate
|
* NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate
|
||||||
*/
|
*/
|
||||||
const RESOLVERS = [
|
const RESOLVERS = [
|
||||||
InfraResolver,
|
|
||||||
AdminResolver,
|
AdminResolver,
|
||||||
ShortcodeResolver,
|
ShortcodeResolver,
|
||||||
TeamResolver,
|
TeamResolver,
|
||||||
@@ -95,7 +93,9 @@ export async function emitGQLSchemaFile() {
|
|||||||
numberScalarMode: 'integer',
|
numberScalarMode: 'integer',
|
||||||
});
|
});
|
||||||
|
|
||||||
const schemaString = printSchema(schema);
|
const schemaString = printSchema(schema, {
|
||||||
|
commentDescriptions: true,
|
||||||
|
});
|
||||||
|
|
||||||
logger.log(`Writing schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`);
|
logger.log(`Writing schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
|
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
|
||||||
protected async getTracker(req: Record<string, any>): Promise<string> {
|
protected getTracker(req: Record<string, any>): string {
|
||||||
return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs
|
return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs
|
||||||
|
// learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For#directives
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
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]: [
|
|
||||||
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_URL,
|
|
||||||
value: process.env.MAILER_SMTP_URL,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: InfraConfigEnum.MAILER_ADDRESS_FROM,
|
|
||||||
value: process.env.MAILER_ADDRESS_FROM,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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',
|
|
||||||
});
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { InfraConfigService } from './infra-config.service';
|
|
||||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
|
||||||
import { SiteController } from './infra-config.controller';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [PrismaModule],
|
|
||||||
providers: [InfraConfigService],
|
|
||||||
exports: [InfraConfigService],
|
|
||||||
controllers: [SiteController],
|
|
||||||
})
|
|
||||||
export class InfraConfigModule {}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
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)),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
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,
|
|
||||||
];
|
|
||||||
// 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:
|
|
||||||
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 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(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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_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.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ export type MailDescription = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type UserMagicLinkMailDescription = {
|
export type UserMagicLinkMailDescription = {
|
||||||
template: 'user-invitation';
|
template: 'code-your-own';
|
||||||
variables: {
|
variables: {
|
||||||
inviteeEmail: string;
|
inviteeEmail: string;
|
||||||
magicLink: string;
|
magicLink: string;
|
||||||
@@ -16,7 +16,7 @@ export type UserMagicLinkMailDescription = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type AdminUserInvitationMailDescription = {
|
export type AdminUserInvitationMailDescription = {
|
||||||
template: 'user-invitation';
|
template: 'code-your-own';
|
||||||
variables: {
|
variables: {
|
||||||
inviteeEmail: string;
|
inviteeEmail: string;
|
||||||
magicLink: string;
|
magicLink: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MailerModule as NestMailerModule } from '@nestjs-modules/mailer';
|
import { MailerModule as NestMailerModule } from '@nestjs-modules/mailer';
|
||||||
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
|
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
|
||||||
import { MailerService } from './mailer.service';
|
import { MailerService } from './mailer.service';
|
||||||
@@ -7,42 +7,24 @@ import {
|
|||||||
MAILER_FROM_ADDRESS_UNDEFINED,
|
MAILER_FROM_ADDRESS_UNDEFINED,
|
||||||
MAILER_SMTP_URL_UNDEFINED,
|
MAILER_SMTP_URL_UNDEFINED,
|
||||||
} from 'src/errors';
|
} from 'src/errors';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { loadInfraConfiguration } from 'src/infra-config/helper';
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
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(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
providers: [MailerService],
|
providers: [MailerService],
|
||||||
exports: [MailerService],
|
exports: [MailerService],
|
||||||
})
|
})
|
||||||
export class MailerModule {
|
export class MailerModule {}
|
||||||
static async register() {
|
|
||||||
const env = await loadInfraConfiguration();
|
|
||||||
|
|
||||||
let mailerSmtpUrl = env.INFRA.MAILER_SMTP_URL;
|
|
||||||
let mailerAddressFrom = env.INFRA.MAILER_ADDRESS_FROM;
|
|
||||||
|
|
||||||
if (!env.INFRA.MAILER_SMTP_URL || !env.INFRA.MAILER_ADDRESS_FROM) {
|
|
||||||
const config = new ConfigService();
|
|
||||||
mailerSmtpUrl = config.get('MAILER_SMTP_URL');
|
|
||||||
mailerAddressFrom = config.get('MAILER_ADDRESS_FROM');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
module: MailerModule,
|
|
||||||
imports: [
|
|
||||||
NestMailerModule.forRoot({
|
|
||||||
transport: mailerSmtpUrl ?? throwErr(MAILER_SMTP_URL_UNDEFINED),
|
|
||||||
defaults: {
|
|
||||||
from: mailerAddressFrom ?? throwErr(MAILER_FROM_ADDRESS_UNDEFINED),
|
|
||||||
},
|
|
||||||
template: {
|
|
||||||
dir: __dirname + '/templates',
|
|
||||||
adapter: new HandlebarsAdapter(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ export class MailerService {
|
|||||||
): string {
|
): string {
|
||||||
switch (mailDesc.template) {
|
switch (mailDesc.template) {
|
||||||
case 'team-invitation':
|
case 'team-invitation':
|
||||||
return `A user has invited you to join a team workspace in Hoppscotch`;
|
return `${mailDesc.variables.invitee} invited you to join ${mailDesc.variables.invite_team_name} in Hoppscotch`;
|
||||||
|
|
||||||
case 'user-invitation':
|
case 'code-your-own':
|
||||||
return 'Sign in to Hoppscotch';
|
return 'Sign in to Hoppscotch';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
-->
|
-->
|
||||||
<style type="text/css" rel="stylesheet" media="all">
|
<style type="text/css" rel="stylesheet" media="all">
|
||||||
/* Base ------------------------------ */
|
/* Base ------------------------------ */
|
||||||
|
|
||||||
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
|
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
|
||||||
body {
|
body {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
@@ -22,25 +22,19 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
-webkit-text-size-adjust: none;
|
-webkit-text-size-adjust: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #3869D4;
|
color: #3869D4;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.nohighlight {
|
|
||||||
color: inherit !important;
|
|
||||||
text-decoration: none !important;
|
|
||||||
cursor: default !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
a img {
|
a img {
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preheader {
|
.preheader {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
@@ -53,13 +47,13 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
/* Type ------------------------------ */
|
/* Type ------------------------------ */
|
||||||
|
|
||||||
body,
|
body,
|
||||||
td,
|
td,
|
||||||
th {
|
th {
|
||||||
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: #333333;
|
color: #333333;
|
||||||
@@ -67,7 +61,7 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: #333333;
|
color: #333333;
|
||||||
@@ -75,7 +69,7 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: #333333;
|
color: #333333;
|
||||||
@@ -83,12 +77,12 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
td,
|
td,
|
||||||
th {
|
th {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p,
|
p,
|
||||||
ul,
|
ul,
|
||||||
ol,
|
ol,
|
||||||
@@ -97,25 +91,25 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.625;
|
line-height: 1.625;
|
||||||
}
|
}
|
||||||
|
|
||||||
p.sub {
|
p.sub {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
/* Utilities ------------------------------ */
|
/* Utilities ------------------------------ */
|
||||||
|
|
||||||
.align-right {
|
.align-right {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.align-left {
|
.align-left {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.align-center {
|
.align-center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
/* Buttons ------------------------------ */
|
/* Buttons ------------------------------ */
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
background-color: #3869D4;
|
background-color: #3869D4;
|
||||||
border-top: 10px solid #3869D4;
|
border-top: 10px solid #3869D4;
|
||||||
@@ -130,7 +124,7 @@
|
|||||||
-webkit-text-size-adjust: none;
|
-webkit-text-size-adjust: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button--green {
|
.button--green {
|
||||||
background-color: #22BC66;
|
background-color: #22BC66;
|
||||||
border-top: 10px solid #22BC66;
|
border-top: 10px solid #22BC66;
|
||||||
@@ -138,7 +132,7 @@
|
|||||||
border-bottom: 10px solid #22BC66;
|
border-bottom: 10px solid #22BC66;
|
||||||
border-left: 18px solid #22BC66;
|
border-left: 18px solid #22BC66;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button--red {
|
.button--red {
|
||||||
background-color: #FF6136;
|
background-color: #FF6136;
|
||||||
border-top: 10px solid #FF6136;
|
border-top: 10px solid #FF6136;
|
||||||
@@ -146,7 +140,7 @@
|
|||||||
border-bottom: 10px solid #FF6136;
|
border-bottom: 10px solid #FF6136;
|
||||||
border-left: 18px solid #FF6136;
|
border-left: 18px solid #FF6136;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 500px) {
|
@media only screen and (max-width: 500px) {
|
||||||
.button {
|
.button {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
@@ -154,21 +148,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* Attribute list ------------------------------ */
|
/* Attribute list ------------------------------ */
|
||||||
|
|
||||||
.attributes {
|
.attributes {
|
||||||
margin: 0 0 21px;
|
margin: 0 0 21px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attributes_content {
|
.attributes_content {
|
||||||
background-color: #F4F4F7;
|
background-color: #F4F4F7;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attributes_item {
|
.attributes_item {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
/* Related Items ------------------------------ */
|
/* Related Items ------------------------------ */
|
||||||
|
|
||||||
.related {
|
.related {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -177,31 +171,31 @@
|
|||||||
-premailer-cellpadding: 0;
|
-premailer-cellpadding: 0;
|
||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.related_item {
|
.related_item {
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
color: #CBCCCF;
|
color: #CBCCCF;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.related_item-title {
|
.related_item-title {
|
||||||
display: block;
|
display: block;
|
||||||
margin: .5em 0 0;
|
margin: .5em 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.related_item-thumb {
|
.related_item-thumb {
|
||||||
display: block;
|
display: block;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.related_heading {
|
.related_heading {
|
||||||
border-top: 1px solid #CBCCCF;
|
border-top: 1px solid #CBCCCF;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 25px 0 10px;
|
padding: 25px 0 10px;
|
||||||
}
|
}
|
||||||
/* Discount Code ------------------------------ */
|
/* Discount Code ------------------------------ */
|
||||||
|
|
||||||
.discount {
|
.discount {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -212,33 +206,33 @@
|
|||||||
background-color: #F4F4F7;
|
background-color: #F4F4F7;
|
||||||
border: 2px dashed #CBCCCF;
|
border: 2px dashed #CBCCCF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discount_heading {
|
.discount_heading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discount_body {
|
.discount_body {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
/* Social Icons ------------------------------ */
|
/* Social Icons ------------------------------ */
|
||||||
|
|
||||||
.social {
|
.social {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.social td {
|
.social td {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.social_icon {
|
.social_icon {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
margin: 0 8px 10px 8px;
|
margin: 0 8px 10px 8px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
/* Data table ------------------------------ */
|
/* Data table ------------------------------ */
|
||||||
|
|
||||||
.purchase {
|
.purchase {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -247,7 +241,7 @@
|
|||||||
-premailer-cellpadding: 0;
|
-premailer-cellpadding: 0;
|
||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.purchase_content {
|
.purchase_content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -256,50 +250,50 @@
|
|||||||
-premailer-cellpadding: 0;
|
-premailer-cellpadding: 0;
|
||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.purchase_item {
|
.purchase_item {
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
color: #51545E;
|
color: #51545E;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.purchase_heading {
|
.purchase_heading {
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
border-bottom: 1px solid #EAEAEC;
|
border-bottom: 1px solid #EAEAEC;
|
||||||
}
|
}
|
||||||
|
|
||||||
.purchase_heading p {
|
.purchase_heading p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #85878E;
|
color: #85878E;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.purchase_footer {
|
.purchase_footer {
|
||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
border-top: 1px solid #EAEAEC;
|
border-top: 1px solid #EAEAEC;
|
||||||
}
|
}
|
||||||
|
|
||||||
.purchase_total {
|
.purchase_total {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #333333;
|
color: #333333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.purchase_total--label {
|
.purchase_total--label {
|
||||||
padding: 0 15px 0 0;
|
padding: 0 15px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #F2F4F6;
|
background-color: #F2F4F6;
|
||||||
color: #51545E;
|
color: #51545E;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
color: #51545E;
|
color: #51545E;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-wrapper {
|
.email-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -309,7 +303,7 @@
|
|||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
background-color: #F2F4F6;
|
background-color: #F2F4F6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-content {
|
.email-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -319,16 +313,16 @@
|
|||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
}
|
}
|
||||||
/* Masthead ----------------------- */
|
/* Masthead ----------------------- */
|
||||||
|
|
||||||
.email-masthead {
|
.email-masthead {
|
||||||
padding: 25px 0;
|
padding: 25px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-masthead_logo {
|
.email-masthead_logo {
|
||||||
width: 94px;
|
width: 94px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-masthead_name {
|
.email-masthead_name {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@@ -337,7 +331,7 @@
|
|||||||
text-shadow: 0 1px 0 white;
|
text-shadow: 0 1px 0 white;
|
||||||
}
|
}
|
||||||
/* Body ------------------------------ */
|
/* Body ------------------------------ */
|
||||||
|
|
||||||
.email-body {
|
.email-body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -346,7 +340,7 @@
|
|||||||
-premailer-cellpadding: 0;
|
-premailer-cellpadding: 0;
|
||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-body_inner {
|
.email-body_inner {
|
||||||
width: 570px;
|
width: 570px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -356,7 +350,7 @@
|
|||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-footer {
|
.email-footer {
|
||||||
width: 570px;
|
width: 570px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -366,11 +360,11 @@
|
|||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-footer p {
|
.email-footer p {
|
||||||
color: #A8AAAF;
|
color: #A8AAAF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.body-action {
|
.body-action {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 30px auto;
|
margin: 30px auto;
|
||||||
@@ -380,25 +374,25 @@
|
|||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.body-sub {
|
.body-sub {
|
||||||
margin-top: 25px;
|
margin-top: 25px;
|
||||||
padding-top: 25px;
|
padding-top: 25px;
|
||||||
border-top: 1px solid #EAEAEC;
|
border-top: 1px solid #EAEAEC;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-cell {
|
.content-cell {
|
||||||
padding: 45px;
|
padding: 45px;
|
||||||
}
|
}
|
||||||
/*Media Queries ------------------------------ */
|
/*Media Queries ------------------------------ */
|
||||||
|
|
||||||
@media only screen and (max-width: 600px) {
|
@media only screen and (max-width: 600px) {
|
||||||
.email-body_inner,
|
.email-body_inner,
|
||||||
.email-footer {
|
.email-footer {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
body,
|
body,
|
||||||
.email-body,
|
.email-body,
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
-->
|
-->
|
||||||
<style type="text/css" rel="stylesheet" media="all">
|
<style type="text/css" rel="stylesheet" media="all">
|
||||||
/* Base ------------------------------ */
|
/* Base ------------------------------ */
|
||||||
|
|
||||||
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
|
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
|
||||||
body {
|
body {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
@@ -22,25 +22,19 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
-webkit-text-size-adjust: none;
|
-webkit-text-size-adjust: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #3869D4;
|
color: #3869D4;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.nohighlight {
|
|
||||||
color: inherit !important;
|
|
||||||
text-decoration: none !important;
|
|
||||||
cursor: default !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
a img {
|
a img {
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preheader {
|
.preheader {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
@@ -53,13 +47,13 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
/* Type ------------------------------ */
|
/* Type ------------------------------ */
|
||||||
|
|
||||||
body,
|
body,
|
||||||
td,
|
td,
|
||||||
th {
|
th {
|
||||||
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: #333333;
|
color: #333333;
|
||||||
@@ -67,7 +61,7 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: #333333;
|
color: #333333;
|
||||||
@@ -75,7 +69,7 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: #333333;
|
color: #333333;
|
||||||
@@ -83,12 +77,12 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
td,
|
td,
|
||||||
th {
|
th {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p,
|
p,
|
||||||
ul,
|
ul,
|
||||||
ol,
|
ol,
|
||||||
@@ -97,25 +91,25 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.625;
|
line-height: 1.625;
|
||||||
}
|
}
|
||||||
|
|
||||||
p.sub {
|
p.sub {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
/* Utilities ------------------------------ */
|
/* Utilities ------------------------------ */
|
||||||
|
|
||||||
.align-right {
|
.align-right {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.align-left {
|
.align-left {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.align-center {
|
.align-center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
/* Buttons ------------------------------ */
|
/* Buttons ------------------------------ */
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
background-color: #3869D4;
|
background-color: #3869D4;
|
||||||
border-top: 10px solid #3869D4;
|
border-top: 10px solid #3869D4;
|
||||||
@@ -130,7 +124,7 @@
|
|||||||
-webkit-text-size-adjust: none;
|
-webkit-text-size-adjust: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button--green {
|
.button--green {
|
||||||
background-color: #22BC66;
|
background-color: #22BC66;
|
||||||
border-top: 10px solid #22BC66;
|
border-top: 10px solid #22BC66;
|
||||||
@@ -138,7 +132,7 @@
|
|||||||
border-bottom: 10px solid #22BC66;
|
border-bottom: 10px solid #22BC66;
|
||||||
border-left: 18px solid #22BC66;
|
border-left: 18px solid #22BC66;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button--red {
|
.button--red {
|
||||||
background-color: #FF6136;
|
background-color: #FF6136;
|
||||||
border-top: 10px solid #FF6136;
|
border-top: 10px solid #FF6136;
|
||||||
@@ -146,7 +140,7 @@
|
|||||||
border-bottom: 10px solid #FF6136;
|
border-bottom: 10px solid #FF6136;
|
||||||
border-left: 18px solid #FF6136;
|
border-left: 18px solid #FF6136;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 500px) {
|
@media only screen and (max-width: 500px) {
|
||||||
.button {
|
.button {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
@@ -154,21 +148,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* Attribute list ------------------------------ */
|
/* Attribute list ------------------------------ */
|
||||||
|
|
||||||
.attributes {
|
.attributes {
|
||||||
margin: 0 0 21px;
|
margin: 0 0 21px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attributes_content {
|
.attributes_content {
|
||||||
background-color: #F4F4F7;
|
background-color: #F4F4F7;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attributes_item {
|
.attributes_item {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
/* Related Items ------------------------------ */
|
/* Related Items ------------------------------ */
|
||||||
|
|
||||||
.related {
|
.related {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -177,31 +171,31 @@
|
|||||||
-premailer-cellpadding: 0;
|
-premailer-cellpadding: 0;
|
||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.related_item {
|
.related_item {
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
color: #CBCCCF;
|
color: #CBCCCF;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.related_item-title {
|
.related_item-title {
|
||||||
display: block;
|
display: block;
|
||||||
margin: .5em 0 0;
|
margin: .5em 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.related_item-thumb {
|
.related_item-thumb {
|
||||||
display: block;
|
display: block;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.related_heading {
|
.related_heading {
|
||||||
border-top: 1px solid #CBCCCF;
|
border-top: 1px solid #CBCCCF;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 25px 0 10px;
|
padding: 25px 0 10px;
|
||||||
}
|
}
|
||||||
/* Discount Code ------------------------------ */
|
/* Discount Code ------------------------------ */
|
||||||
|
|
||||||
.discount {
|
.discount {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -212,33 +206,33 @@
|
|||||||
background-color: #F4F4F7;
|
background-color: #F4F4F7;
|
||||||
border: 2px dashed #CBCCCF;
|
border: 2px dashed #CBCCCF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discount_heading {
|
.discount_heading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discount_body {
|
.discount_body {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
/* Social Icons ------------------------------ */
|
/* Social Icons ------------------------------ */
|
||||||
|
|
||||||
.social {
|
.social {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.social td {
|
.social td {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.social_icon {
|
.social_icon {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
margin: 0 8px 10px 8px;
|
margin: 0 8px 10px 8px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
/* Data table ------------------------------ */
|
/* Data table ------------------------------ */
|
||||||
|
|
||||||
.purchase {
|
.purchase {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -247,7 +241,7 @@
|
|||||||
-premailer-cellpadding: 0;
|
-premailer-cellpadding: 0;
|
||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.purchase_content {
|
.purchase_content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -256,50 +250,50 @@
|
|||||||
-premailer-cellpadding: 0;
|
-premailer-cellpadding: 0;
|
||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.purchase_item {
|
.purchase_item {
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
color: #51545E;
|
color: #51545E;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.purchase_heading {
|
.purchase_heading {
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
border-bottom: 1px solid #EAEAEC;
|
border-bottom: 1px solid #EAEAEC;
|
||||||
}
|
}
|
||||||
|
|
||||||
.purchase_heading p {
|
.purchase_heading p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #85878E;
|
color: #85878E;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.purchase_footer {
|
.purchase_footer {
|
||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
border-top: 1px solid #EAEAEC;
|
border-top: 1px solid #EAEAEC;
|
||||||
}
|
}
|
||||||
|
|
||||||
.purchase_total {
|
.purchase_total {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #333333;
|
color: #333333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.purchase_total--label {
|
.purchase_total--label {
|
||||||
padding: 0 15px 0 0;
|
padding: 0 15px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #F2F4F6;
|
background-color: #F2F4F6;
|
||||||
color: #51545E;
|
color: #51545E;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
color: #51545E;
|
color: #51545E;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-wrapper {
|
.email-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -309,7 +303,7 @@
|
|||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
background-color: #F2F4F6;
|
background-color: #F2F4F6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-content {
|
.email-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -319,16 +313,16 @@
|
|||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
}
|
}
|
||||||
/* Masthead ----------------------- */
|
/* Masthead ----------------------- */
|
||||||
|
|
||||||
.email-masthead {
|
.email-masthead {
|
||||||
padding: 25px 0;
|
padding: 25px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-masthead_logo {
|
.email-masthead_logo {
|
||||||
width: 94px;
|
width: 94px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-masthead_name {
|
.email-masthead_name {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@@ -337,7 +331,7 @@
|
|||||||
text-shadow: 0 1px 0 white;
|
text-shadow: 0 1px 0 white;
|
||||||
}
|
}
|
||||||
/* Body ------------------------------ */
|
/* Body ------------------------------ */
|
||||||
|
|
||||||
.email-body {
|
.email-body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -346,7 +340,7 @@
|
|||||||
-premailer-cellpadding: 0;
|
-premailer-cellpadding: 0;
|
||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-body_inner {
|
.email-body_inner {
|
||||||
width: 570px;
|
width: 570px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -356,7 +350,7 @@
|
|||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-footer {
|
.email-footer {
|
||||||
width: 570px;
|
width: 570px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -366,11 +360,11 @@
|
|||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-footer p {
|
.email-footer p {
|
||||||
color: #A8AAAF;
|
color: #A8AAAF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.body-action {
|
.body-action {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 30px auto;
|
margin: 30px auto;
|
||||||
@@ -380,25 +374,25 @@
|
|||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.body-sub {
|
.body-sub {
|
||||||
margin-top: 25px;
|
margin-top: 25px;
|
||||||
padding-top: 25px;
|
padding-top: 25px;
|
||||||
border-top: 1px solid #EAEAEC;
|
border-top: 1px solid #EAEAEC;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-cell {
|
.content-cell {
|
||||||
padding: 45px;
|
padding: 45px;
|
||||||
}
|
}
|
||||||
/*Media Queries ------------------------------ */
|
/*Media Queries ------------------------------ */
|
||||||
|
|
||||||
@media only screen and (max-width: 600px) {
|
@media only screen and (max-width: 600px) {
|
||||||
.email-body_inner,
|
.email-body_inner,
|
||||||
.email-footer {
|
.email-footer {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
body,
|
body,
|
||||||
.email-body,
|
.email-body,
|
||||||
@@ -464,7 +458,7 @@
|
|||||||
<td class="content-cell">
|
<td class="content-cell">
|
||||||
<div class="f-fallback">
|
<div class="f-fallback">
|
||||||
<h1>Hi there,</h1>
|
<h1>Hi there,</h1>
|
||||||
<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>
|
<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>
|
||||||
<!-- Action -->
|
<!-- Action -->
|
||||||
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
|
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -490,7 +484,7 @@
|
|||||||
Welcome aboard, <br />
|
Welcome aboard, <br />
|
||||||
Your friends at Hoppscotch
|
Your friends at Hoppscotch
|
||||||
</p>
|
</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>
|
<p><strong>P.S.</strong> If you don't associate with {{invitee}} or {{invite_team_name}}, just ignore this email.</p>
|
||||||
<!-- Sub copy -->
|
<!-- Sub copy -->
|
||||||
<table class="body-sub">
|
<table class="body-sub">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -6,24 +6,18 @@ import { VersioningType } from '@nestjs/common';
|
|||||||
import * as session from 'express-session';
|
import * as session from 'express-session';
|
||||||
import { emitGQLSchemaFile } from './gql-schema';
|
import { emitGQLSchemaFile } from './gql-schema';
|
||||||
import { checkEnvironmentAuthProvider } from './utils';
|
import { checkEnvironmentAuthProvider } from './utils';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
|
|
||||||
async function bootstrap() {
|
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 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(
|
app.use(
|
||||||
session({
|
session({
|
||||||
secret: configService.get('SESSION_SECRET'),
|
secret: process.env.SESSION_SECRET,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -34,18 +28,18 @@ async function bootstrap() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (configService.get('PRODUCTION') === 'false') {
|
if (process.env.PRODUCTION === 'false') {
|
||||||
console.log('Enabling CORS with development settings');
|
console.log('Enabling CORS with development settings');
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: configService.get('WHITELISTED_ORIGINS').split(','),
|
origin: process.env.WHITELISTED_ORIGINS.split(','),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log('Enabling CORS with production settings');
|
console.log('Enabling CORS with production settings');
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: configService.get('WHITELISTED_ORIGINS').split(','),
|
origin: process.env.WHITELISTED_ORIGINS.split(','),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -53,13 +47,7 @@ async function bootstrap() {
|
|||||||
type: VersioningType.URI,
|
type: VersioningType.URI,
|
||||||
});
|
});
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
await app.listen(configService.get('PORT') || 3170);
|
await app.listen(process.env.PORT || 3170);
|
||||||
|
|
||||||
// Graceful shutdown
|
|
||||||
process.on('SIGTERM', async () => {
|
|
||||||
console.info('SIGTERM signal received');
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!process.env.GENERATE_GQL_SCHEMA) {
|
if (!process.env.GENERATE_GQL_SCHEMA) {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { GraphQLSchemaHost } from '@nestjs/graphql';
|
import { GraphQLSchemaHost } from '@nestjs/graphql';
|
||||||
import {
|
import {
|
||||||
ApolloServerPlugin,
|
ApolloServerPlugin,
|
||||||
BaseContext,
|
|
||||||
GraphQLRequestListener,
|
GraphQLRequestListener,
|
||||||
} from '@apollo/server';
|
} from 'apollo-server-plugin-base';
|
||||||
import { Plugin } from '@nestjs/apollo';
|
import { Plugin } from '@nestjs/apollo';
|
||||||
import { GraphQLError } from 'graphql';
|
import { GraphQLError } from 'graphql';
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +17,7 @@ const COMPLEXITY_LIMIT = 50;
|
|||||||
export class GQLComplexityPlugin implements ApolloServerPlugin {
|
export class GQLComplexityPlugin implements ApolloServerPlugin {
|
||||||
constructor(private gqlSchemaHost: GraphQLSchemaHost) {}
|
constructor(private gqlSchemaHost: GraphQLSchemaHost) {}
|
||||||
|
|
||||||
async requestDidStart(): Promise<GraphQLRequestListener<BaseContext>> {
|
async requestDidStart(): Promise<GraphQLRequestListener> {
|
||||||
const { schema } = this.gqlSchemaHost;
|
const { schema } = this.gqlSchemaHost;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { PostHog } from 'posthog-node';
|
|
||||||
import { Cron, CronExpression, SchedulerRegistry } from '@nestjs/schedule';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
|
||||||
import { CronJob } from 'cron';
|
|
||||||
import { POSTHOG_CLIENT_NOT_INITIALIZED } from 'src/errors';
|
|
||||||
import { throwErr } from 'src/utils';
|
|
||||||
@Injectable()
|
|
||||||
export class PosthogService {
|
|
||||||
private postHogClient: PostHog;
|
|
||||||
private POSTHOG_API_KEY = 'phc_9CipPajQC22mSkk2wxe2TXsUA0Ysyupe8dt5KQQELqx';
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
private readonly prismaService: PrismaService,
|
|
||||||
private schedulerRegistry: SchedulerRegistry,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async onModuleInit() {
|
|
||||||
if (this.configService.get('INFRA.ALLOW_ANALYTICS_COLLECTION') === 'true') {
|
|
||||||
console.log('Initializing PostHog');
|
|
||||||
this.postHogClient = new PostHog(this.POSTHOG_API_KEY, {
|
|
||||||
host: 'https://eu.posthog.com',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Schedule the cron job only if analytics collection is allowed
|
|
||||||
this.scheduleCronJob();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private scheduleCronJob() {
|
|
||||||
const job = new CronJob(CronExpression.EVERY_WEEK, async () => {
|
|
||||||
await this.capture();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.schedulerRegistry.addCronJob('captureAnalytics', job);
|
|
||||||
job.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
async capture() {
|
|
||||||
if (!this.postHogClient) {
|
|
||||||
throwErr(POSTHOG_CLIENT_NOT_INITIALIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.postHogClient.capture({
|
|
||||||
distinctId: this.configService.get('INFRA.ANALYTICS_USER_ID'),
|
|
||||||
event: 'sh_instance',
|
|
||||||
properties: {
|
|
||||||
type: 'COMMUNITY',
|
|
||||||
total_user_count: await this.prismaService.user.count(),
|
|
||||||
total_workspace_count: await this.prismaService.team.count(),
|
|
||||||
version: this.configService.get('npm_package_version'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log('Sent event to PostHog');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,8 +21,8 @@ import {
|
|||||||
} from 'src/team-request/team-request.model';
|
} from 'src/team-request/team-request.model';
|
||||||
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
|
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
|
||||||
import { InvitedUser } from '../admin/invited-user.model';
|
import { InvitedUser } from '../admin/invited-user.model';
|
||||||
|
import { UserCollection } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
UserCollection,
|
|
||||||
UserCollectionRemovedData,
|
UserCollectionRemovedData,
|
||||||
UserCollectionReorderData,
|
UserCollectionReorderData,
|
||||||
} from 'src/user-collection/user-collections.model';
|
} from 'src/user-collection/user-collections.model';
|
||||||
@@ -69,7 +69,5 @@ export type TopicDef = {
|
|||||||
[topic: `team_req/${string}/req_deleted`]: string;
|
[topic: `team_req/${string}/req_deleted`]: string;
|
||||||
[topic: `team/${string}/invite_added`]: TeamInvitation;
|
[topic: `team/${string}/invite_added`]: TeamInvitation;
|
||||||
[topic: `team/${string}/invite_removed`]: string;
|
[topic: `team/${string}/invite_removed`]: string;
|
||||||
[
|
[topic: `shortcode/${string}/${'created' | 'revoked'}`]: Shortcode;
|
||||||
topic: `shortcode/${string}/${'created' | 'revoked' | 'updated'}`
|
|
||||||
]: Shortcode;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
||||||
import { User } from 'src/user/user.model';
|
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
export class Shortcode {
|
export class Shortcode {
|
||||||
@Field(() => ID, {
|
@Field(() => ID, {
|
||||||
description: 'The 12 digit alphanumeric code',
|
description: 'The shortcode. 12 digit alphanumeric.',
|
||||||
})
|
})
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@@ -13,57 +12,8 @@ export class Shortcode {
|
|||||||
})
|
})
|
||||||
request: string;
|
request: string;
|
||||||
|
|
||||||
@Field({
|
|
||||||
description: 'JSON string representing the properties for an embed',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
properties: string;
|
|
||||||
|
|
||||||
@Field({
|
@Field({
|
||||||
description: 'Timestamp of when the Shortcode was created',
|
description: 'Timestamp of when the Shortcode was created',
|
||||||
})
|
})
|
||||||
createdOn: Date;
|
createdOn: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ObjectType()
|
|
||||||
export class ShortcodeCreator {
|
|
||||||
@Field({
|
|
||||||
description: 'Uid of user who created the shortcode',
|
|
||||||
})
|
|
||||||
uid: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
description: 'Email of user who created the shortcode',
|
|
||||||
})
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ObjectType()
|
|
||||||
export class ShortcodeWithUserEmail {
|
|
||||||
@Field(() => ID, {
|
|
||||||
description: 'The 12 digit alphanumeric code',
|
|
||||||
})
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
description: 'JSON string representing the request data',
|
|
||||||
})
|
|
||||||
request: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
description: 'JSON string representing the properties for an embed',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
properties: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
description: 'Timestamp of when the Shortcode was created',
|
|
||||||
})
|
|
||||||
createdOn: Date;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
description: 'Details of user who created the shortcode',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
creator: ShortcodeCreator;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||||
import { PubSubModule } from 'src/pubsub/pubsub.module';
|
import { PubSubModule } from 'src/pubsub/pubsub.module';
|
||||||
import { UserModule } from 'src/user/user.module';
|
import { UserModule } from 'src/user/user.module';
|
||||||
@@ -6,7 +7,14 @@ import { ShortcodeResolver } from './shortcode.resolver';
|
|||||||
import { ShortcodeService } from './shortcode.service';
|
import { ShortcodeService } from './shortcode.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, UserModule, PubSubModule],
|
imports: [
|
||||||
|
PrismaModule,
|
||||||
|
UserModule,
|
||||||
|
PubSubModule,
|
||||||
|
JwtModule.register({
|
||||||
|
secret: process.env.JWT_SECRET,
|
||||||
|
}),
|
||||||
|
],
|
||||||
providers: [ShortcodeService, ShortcodeResolver],
|
providers: [ShortcodeService, ShortcodeResolver],
|
||||||
exports: [ShortcodeService],
|
exports: [ShortcodeService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
|
Context,
|
||||||
ID,
|
ID,
|
||||||
Mutation,
|
Mutation,
|
||||||
Query,
|
Query,
|
||||||
@@ -8,25 +9,28 @@ import {
|
|||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
import * as E from 'fp-ts/Either';
|
import * as E from 'fp-ts/Either';
|
||||||
import { UseGuards } from '@nestjs/common';
|
import { UseGuards } from '@nestjs/common';
|
||||||
import { Shortcode, ShortcodeWithUserEmail } from './shortcode.model';
|
import { Shortcode } from './shortcode.model';
|
||||||
import { ShortcodeService } from './shortcode.service';
|
import { ShortcodeService } from './shortcode.service';
|
||||||
|
import { UserService } from 'src/user/user.service';
|
||||||
import { throwErr } from 'src/utils';
|
import { throwErr } from 'src/utils';
|
||||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||||
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
|
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
|
||||||
import { User } from 'src/user/user.model';
|
import { User } from 'src/user/user.model';
|
||||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||||
import { AuthUser } from '../types/AuthUser';
|
import { AuthUser } from '../types/AuthUser';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { PaginationArgs } from 'src/types/input-types.args';
|
import { PaginationArgs } from 'src/types/input-types.args';
|
||||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
||||||
import { SkipThrottle } from '@nestjs/throttler';
|
import { SkipThrottle } from '@nestjs/throttler';
|
||||||
import { GqlAdminGuard } from 'src/admin/guards/gql-admin.guard';
|
|
||||||
|
|
||||||
@UseGuards(GqlThrottlerGuard)
|
@UseGuards(GqlThrottlerGuard)
|
||||||
@Resolver(() => Shortcode)
|
@Resolver(() => Shortcode)
|
||||||
export class ShortcodeResolver {
|
export class ShortcodeResolver {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly shortcodeService: ShortcodeService,
|
private readonly shortcodeService: ShortcodeService,
|
||||||
|
private readonly userService: UserService,
|
||||||
private readonly pubsub: PubSubService,
|
private readonly pubsub: PubSubService,
|
||||||
|
private jwtService: JwtService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/* Queries */
|
/* Queries */
|
||||||
@@ -60,53 +64,20 @@ export class ShortcodeResolver {
|
|||||||
@Mutation(() => Shortcode, {
|
@Mutation(() => Shortcode, {
|
||||||
description: 'Create a shortcode for the given request.',
|
description: 'Create a shortcode for the given request.',
|
||||||
})
|
})
|
||||||
@UseGuards(GqlAuthGuard)
|
|
||||||
async createShortcode(
|
async createShortcode(
|
||||||
@GqlUser() user: AuthUser,
|
|
||||||
@Args({
|
@Args({
|
||||||
name: 'request',
|
name: 'request',
|
||||||
description: 'JSON string of the request object',
|
description: 'JSON string of the request object',
|
||||||
})
|
})
|
||||||
request: string,
|
request: string,
|
||||||
@Args({
|
@Context() ctx: any,
|
||||||
name: 'properties',
|
|
||||||
description: 'JSON string of the properties of the embed',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
properties: string,
|
|
||||||
) {
|
) {
|
||||||
|
const decodedAccessToken = this.jwtService.verify(
|
||||||
|
ctx.req.cookies['access_token'],
|
||||||
|
);
|
||||||
const result = await this.shortcodeService.createShortcode(
|
const result = await this.shortcodeService.createShortcode(
|
||||||
request,
|
request,
|
||||||
properties,
|
decodedAccessToken?.sub,
|
||||||
user,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (E.isLeft(result)) throwErr(result.left);
|
|
||||||
return result.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => Shortcode, {
|
|
||||||
description: 'Update a user generated Shortcode',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard)
|
|
||||||
async updateEmbedProperties(
|
|
||||||
@GqlUser() user: AuthUser,
|
|
||||||
@Args({
|
|
||||||
name: 'code',
|
|
||||||
type: () => ID,
|
|
||||||
description: 'The Shortcode to update',
|
|
||||||
})
|
|
||||||
code: string,
|
|
||||||
@Args({
|
|
||||||
name: 'properties',
|
|
||||||
description: 'JSON string of the properties of the embed',
|
|
||||||
})
|
|
||||||
properties: string,
|
|
||||||
) {
|
|
||||||
const result = await this.shortcodeService.updateEmbedProperties(
|
|
||||||
code,
|
|
||||||
user.uid,
|
|
||||||
properties,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (E.isLeft(result)) throwErr(result.left);
|
if (E.isLeft(result)) throwErr(result.left);
|
||||||
@@ -122,7 +93,7 @@ export class ShortcodeResolver {
|
|||||||
@Args({
|
@Args({
|
||||||
name: 'code',
|
name: 'code',
|
||||||
type: () => ID,
|
type: () => ID,
|
||||||
description: 'The shortcode to remove',
|
description: 'The shortcode to resolve',
|
||||||
})
|
})
|
||||||
code: string,
|
code: string,
|
||||||
) {
|
) {
|
||||||
@@ -143,16 +114,6 @@ export class ShortcodeResolver {
|
|||||||
return this.pubsub.asyncIterator(`shortcode/${user.uid}/created`);
|
return this.pubsub.asyncIterator(`shortcode/${user.uid}/created`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Subscription(() => Shortcode, {
|
|
||||||
description: 'Listen for Shortcode updates',
|
|
||||||
resolve: (value) => value,
|
|
||||||
})
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(GqlAuthGuard)
|
|
||||||
myShortcodesUpdated(@GqlUser() user: AuthUser) {
|
|
||||||
return this.pubsub.asyncIterator(`shortcode/${user.uid}/updated`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Subscription(() => Shortcode, {
|
@Subscription(() => Shortcode, {
|
||||||
description: 'Listen for shortcode deletion',
|
description: 'Listen for shortcode deletion',
|
||||||
resolve: (value) => value,
|
resolve: (value) => value,
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
INVALID_EMAIL,
|
SHORTCODE_ALREADY_EXISTS,
|
||||||
SHORTCODE_INVALID_PROPERTIES_JSON,
|
SHORTCODE_INVALID_JSON,
|
||||||
SHORTCODE_INVALID_REQUEST_JSON,
|
|
||||||
SHORTCODE_NOT_FOUND,
|
SHORTCODE_NOT_FOUND,
|
||||||
SHORTCODE_PROPERTIES_NOT_FOUND,
|
|
||||||
} from 'src/errors';
|
} from 'src/errors';
|
||||||
import { Shortcode, ShortcodeWithUserEmail } from './shortcode.model';
|
import { Shortcode } from './shortcode.model';
|
||||||
import { ShortcodeService } from './shortcode.service';
|
import { ShortcodeService } from './shortcode.service';
|
||||||
import { UserService } from 'src/user/user.service';
|
import { UserService } from 'src/user/user.service';
|
||||||
import { AuthUser } from 'src/types/AuthUser';
|
|
||||||
|
|
||||||
const mockPrisma = mockDeep<PrismaService>();
|
const mockPrisma = mockDeep<PrismaService>();
|
||||||
|
|
||||||
@@ -25,7 +22,7 @@ const mockFB = {
|
|||||||
doc: mockDocFunc,
|
doc: mockDocFunc,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const mockUserService = new UserService(mockPrisma as any, mockPubSub as any);
|
const mockUserService = new UserService(mockFB as any, mockPubSub as any);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -41,34 +38,18 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
const createdOn = new Date();
|
const createdOn = new Date();
|
||||||
|
|
||||||
const user: AuthUser = {
|
const shortCodeWithOutUser = {
|
||||||
uid: '123344',
|
|
||||||
email: 'dwight@dundermifflin.com',
|
|
||||||
displayName: 'Dwight Schrute',
|
|
||||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
|
||||||
isAdmin: false,
|
|
||||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
|
||||||
createdOn: createdOn,
|
|
||||||
currentGQLSession: {},
|
|
||||||
currentRESTSession: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockEmbed = {
|
|
||||||
id: '123',
|
id: '123',
|
||||||
request: '{}',
|
request: '{}',
|
||||||
embedProperties: '{}',
|
|
||||||
createdOn: createdOn,
|
createdOn: createdOn,
|
||||||
creatorUid: user.uid,
|
creatorUid: null,
|
||||||
updatedOn: createdOn,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockShortcode = {
|
const shortCodeWithUser = {
|
||||||
id: '123',
|
id: '123',
|
||||||
request: '{}',
|
request: '{}',
|
||||||
embedProperties: null,
|
|
||||||
createdOn: createdOn,
|
createdOn: createdOn,
|
||||||
creatorUid: user.uid,
|
creatorUid: 'user_uid_1',
|
||||||
updatedOn: createdOn,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const shortcodes = [
|
const shortcodes = [
|
||||||
@@ -77,67 +58,33 @@ const shortcodes = [
|
|||||||
request: {
|
request: {
|
||||||
hello: 'there',
|
hello: 'there',
|
||||||
},
|
},
|
||||||
embedProperties: {
|
creatorUid: 'testuser',
|
||||||
foo: 'bar',
|
|
||||||
},
|
|
||||||
creatorUid: user.uid,
|
|
||||||
createdOn: new Date(),
|
createdOn: new Date(),
|
||||||
updatedOn: createdOn,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'blablabla1',
|
id: 'blablabla1',
|
||||||
request: {
|
request: {
|
||||||
hello: 'there',
|
hello: 'there',
|
||||||
},
|
},
|
||||||
embedProperties: {
|
creatorUid: 'testuser',
|
||||||
foo: 'bar',
|
|
||||||
},
|
|
||||||
creatorUid: user.uid,
|
|
||||||
createdOn: new Date(),
|
createdOn: new Date(),
|
||||||
updatedOn: createdOn,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const shortcodesWithUserEmail = [
|
|
||||||
{
|
|
||||||
id: 'blablabla',
|
|
||||||
request: {
|
|
||||||
hello: 'there',
|
|
||||||
},
|
|
||||||
embedProperties: {
|
|
||||||
foo: 'bar',
|
|
||||||
},
|
|
||||||
creatorUid: user.uid,
|
|
||||||
createdOn: new Date(),
|
|
||||||
updatedOn: createdOn,
|
|
||||||
User: user,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'blablabla1',
|
|
||||||
request: {
|
|
||||||
hello: 'there',
|
|
||||||
},
|
|
||||||
embedProperties: {
|
|
||||||
foo: 'bar',
|
|
||||||
},
|
|
||||||
creatorUid: user.uid,
|
|
||||||
createdOn: new Date(),
|
|
||||||
updatedOn: createdOn,
|
|
||||||
User: user,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('ShortcodeService', () => {
|
describe('ShortcodeService', () => {
|
||||||
describe('getShortCode', () => {
|
describe('getShortCode', () => {
|
||||||
test('should return a valid Shortcode with valid Shortcode ID', async () => {
|
test('should return a valid shortcode with valid shortcode ID', async () => {
|
||||||
mockPrisma.shortcode.findFirstOrThrow.mockResolvedValueOnce(mockEmbed);
|
mockPrisma.shortcode.findFirstOrThrow.mockResolvedValueOnce(
|
||||||
|
shortCodeWithOutUser,
|
||||||
|
);
|
||||||
|
|
||||||
const result = await shortcodeService.getShortCode(mockEmbed.id);
|
const result = await shortcodeService.getShortCode(
|
||||||
|
shortCodeWithOutUser.id,
|
||||||
|
);
|
||||||
expect(result).toEqualRight(<Shortcode>{
|
expect(result).toEqualRight(<Shortcode>{
|
||||||
id: mockEmbed.id,
|
id: shortCodeWithOutUser.id,
|
||||||
createdOn: mockEmbed.createdOn,
|
createdOn: shortCodeWithOutUser.createdOn,
|
||||||
request: JSON.stringify(mockEmbed.request),
|
request: JSON.stringify(shortCodeWithOutUser.request),
|
||||||
properties: JSON.stringify(mockEmbed.embedProperties),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,10 +99,10 @@ describe('ShortcodeService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('fetchUserShortCodes', () => {
|
describe('fetchUserShortCodes', () => {
|
||||||
test('should return list of Shortcode with valid inputs and no cursor', async () => {
|
test('should return list of shortcodes with valid inputs and no cursor', async () => {
|
||||||
mockPrisma.shortcode.findMany.mockResolvedValueOnce(shortcodes);
|
mockPrisma.shortcode.findMany.mockResolvedValueOnce(shortcodes);
|
||||||
|
|
||||||
const result = await shortcodeService.fetchUserShortCodes(user.uid, {
|
const result = await shortcodeService.fetchUserShortCodes('testuser', {
|
||||||
cursor: null,
|
cursor: null,
|
||||||
take: 10,
|
take: 10,
|
||||||
});
|
});
|
||||||
@@ -163,22 +110,20 @@ describe('ShortcodeService', () => {
|
|||||||
{
|
{
|
||||||
id: shortcodes[0].id,
|
id: shortcodes[0].id,
|
||||||
request: JSON.stringify(shortcodes[0].request),
|
request: JSON.stringify(shortcodes[0].request),
|
||||||
properties: JSON.stringify(shortcodes[0].embedProperties),
|
|
||||||
createdOn: shortcodes[0].createdOn,
|
createdOn: shortcodes[0].createdOn,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: shortcodes[1].id,
|
id: shortcodes[1].id,
|
||||||
request: JSON.stringify(shortcodes[1].request),
|
request: JSON.stringify(shortcodes[1].request),
|
||||||
properties: JSON.stringify(shortcodes[1].embedProperties),
|
|
||||||
createdOn: shortcodes[1].createdOn,
|
createdOn: shortcodes[1].createdOn,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return list of Shortcode with valid inputs and cursor', async () => {
|
test('should return list of shortcodes with valid inputs and cursor', async () => {
|
||||||
mockPrisma.shortcode.findMany.mockResolvedValue([shortcodes[1]]);
|
mockPrisma.shortcode.findMany.mockResolvedValue([shortcodes[1]]);
|
||||||
|
|
||||||
const result = await shortcodeService.fetchUserShortCodes(user.uid, {
|
const result = await shortcodeService.fetchUserShortCodes('testuser', {
|
||||||
cursor: 'blablabla',
|
cursor: 'blablabla',
|
||||||
take: 10,
|
take: 10,
|
||||||
});
|
});
|
||||||
@@ -186,7 +131,6 @@ describe('ShortcodeService', () => {
|
|||||||
{
|
{
|
||||||
id: shortcodes[1].id,
|
id: shortcodes[1].id,
|
||||||
request: JSON.stringify(shortcodes[1].request),
|
request: JSON.stringify(shortcodes[1].request),
|
||||||
properties: JSON.stringify(shortcodes[1].embedProperties),
|
|
||||||
createdOn: shortcodes[1].createdOn,
|
createdOn: shortcodes[1].createdOn,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -195,7 +139,7 @@ describe('ShortcodeService', () => {
|
|||||||
test('should return an empty array for an invalid cursor', async () => {
|
test('should return an empty array for an invalid cursor', async () => {
|
||||||
mockPrisma.shortcode.findMany.mockResolvedValue([]);
|
mockPrisma.shortcode.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await shortcodeService.fetchUserShortCodes(user.uid, {
|
const result = await shortcodeService.fetchUserShortCodes('testuser', {
|
||||||
cursor: 'invalidcursor',
|
cursor: 'invalidcursor',
|
||||||
take: 10,
|
take: 10,
|
||||||
});
|
});
|
||||||
@@ -227,111 +171,77 @@ describe('ShortcodeService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('createShortcode', () => {
|
describe('createShortcode', () => {
|
||||||
test('should throw SHORTCODE_INVALID_REQUEST_JSON error if incoming request data is invalid', async () => {
|
test('should throw SHORTCODE_INVALID_JSON error if incoming request data is invalid', async () => {
|
||||||
const result = await shortcodeService.createShortcode(
|
const result = await shortcodeService.createShortcode(
|
||||||
'invalidRequest',
|
'invalidRequest',
|
||||||
null,
|
'user_uid_1',
|
||||||
user,
|
|
||||||
);
|
);
|
||||||
expect(result).toEqualLeft(SHORTCODE_INVALID_REQUEST_JSON);
|
expect(result).toEqualLeft(SHORTCODE_INVALID_JSON);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw SHORTCODE_INVALID_PROPERTIES_JSON error if incoming properties data is invalid', async () => {
|
test('should successfully create a new shortcode with valid user uid', async () => {
|
||||||
const result = await shortcodeService.createShortcode(
|
// generateUniqueShortCodeID --> getShortCode
|
||||||
'{}',
|
|
||||||
'invalid_data',
|
|
||||||
user,
|
|
||||||
);
|
|
||||||
expect(result).toEqualLeft(SHORTCODE_INVALID_PROPERTIES_JSON);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should successfully create a new Embed with valid user uid', async () => {
|
|
||||||
// generateUniqueShortCodeID --> getShortcode
|
|
||||||
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
|
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
|
||||||
'NotFoundError',
|
'NotFoundError',
|
||||||
);
|
);
|
||||||
mockPrisma.shortcode.create.mockResolvedValueOnce(mockEmbed);
|
mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser);
|
||||||
|
|
||||||
const result = await shortcodeService.createShortcode('{}', '{}', user);
|
const result = await shortcodeService.createShortcode('{}', 'user_uid_1');
|
||||||
expect(result).toEqualRight(<Shortcode>{
|
expect(result).toEqualRight({
|
||||||
id: mockEmbed.id,
|
id: shortCodeWithUser.id,
|
||||||
createdOn: mockEmbed.createdOn,
|
createdOn: shortCodeWithUser.createdOn,
|
||||||
request: JSON.stringify(mockEmbed.request),
|
request: JSON.stringify(shortCodeWithUser.request),
|
||||||
properties: JSON.stringify(mockEmbed.embedProperties),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should successfully create a new ShortCode with valid user uid', async () => {
|
test('should successfully create a new shortcode with null user uid', async () => {
|
||||||
// generateUniqueShortCodeID --> getShortcode
|
// generateUniqueShortCodeID --> getShortCode
|
||||||
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
|
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
|
||||||
'NotFoundError',
|
'NotFoundError',
|
||||||
);
|
);
|
||||||
mockPrisma.shortcode.create.mockResolvedValueOnce(mockShortcode);
|
mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser);
|
||||||
|
|
||||||
const result = await shortcodeService.createShortcode('{}', null, user);
|
const result = await shortcodeService.createShortcode('{}', null);
|
||||||
expect(result).toEqualRight(<Shortcode>{
|
expect(result).toEqualRight({
|
||||||
id: mockShortcode.id,
|
id: shortCodeWithUser.id,
|
||||||
createdOn: mockShortcode.createdOn,
|
createdOn: shortCodeWithUser.createdOn,
|
||||||
request: JSON.stringify(mockShortcode.request),
|
request: JSON.stringify(shortCodeWithOutUser.request),
|
||||||
properties: mockShortcode.embedProperties,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should send pubsub message to `shortcode/{uid}/created` on successful creation of a Shortcode', async () => {
|
test('should send pubsub message to `shortcode/{uid}/created` on successful creation of shortcode', async () => {
|
||||||
// generateUniqueShortCodeID --> getShortcode
|
// generateUniqueShortCodeID --> getShortCode
|
||||||
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
|
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
|
||||||
'NotFoundError',
|
'NotFoundError',
|
||||||
);
|
);
|
||||||
mockPrisma.shortcode.create.mockResolvedValueOnce(mockShortcode);
|
mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser);
|
||||||
|
|
||||||
const result = await shortcodeService.createShortcode('{}', null, user);
|
|
||||||
|
|
||||||
|
const result = await shortcodeService.createShortcode('{}', 'user_uid_1');
|
||||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||||
`shortcode/${mockShortcode.creatorUid}/created`,
|
`shortcode/${shortCodeWithUser.creatorUid}/created`,
|
||||||
<Shortcode>{
|
{
|
||||||
id: mockShortcode.id,
|
id: shortCodeWithUser.id,
|
||||||
createdOn: mockShortcode.createdOn,
|
createdOn: shortCodeWithUser.createdOn,
|
||||||
request: JSON.stringify(mockShortcode.request),
|
request: JSON.stringify(shortCodeWithUser.request),
|
||||||
properties: mockShortcode.embedProperties,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should send pubsub message to `shortcode/{uid}/created` on successful creation of an Embed', async () => {
|
|
||||||
// generateUniqueShortCodeID --> getShortcode
|
|
||||||
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
|
|
||||||
'NotFoundError',
|
|
||||||
);
|
|
||||||
mockPrisma.shortcode.create.mockResolvedValueOnce(mockEmbed);
|
|
||||||
|
|
||||||
const result = await shortcodeService.createShortcode('{}', '{}', user);
|
|
||||||
|
|
||||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
|
||||||
`shortcode/${mockEmbed.creatorUid}/created`,
|
|
||||||
<Shortcode>{
|
|
||||||
id: mockEmbed.id,
|
|
||||||
createdOn: mockEmbed.createdOn,
|
|
||||||
request: JSON.stringify(mockEmbed.request),
|
|
||||||
properties: JSON.stringify(mockEmbed.embedProperties),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('revokeShortCode', () => {
|
describe('revokeShortCode', () => {
|
||||||
test('should return true on successful deletion of Shortcode with valid inputs', async () => {
|
test('should return true on successful deletion of shortcode with valid inputs', async () => {
|
||||||
mockPrisma.shortcode.delete.mockResolvedValueOnce(mockEmbed);
|
mockPrisma.shortcode.delete.mockResolvedValueOnce(shortCodeWithUser);
|
||||||
|
|
||||||
const result = await shortcodeService.revokeShortCode(
|
const result = await shortcodeService.revokeShortCode(
|
||||||
mockEmbed.id,
|
shortCodeWithUser.id,
|
||||||
mockEmbed.creatorUid,
|
shortCodeWithUser.creatorUid,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockPrisma.shortcode.delete).toHaveBeenCalledWith({
|
expect(mockPrisma.shortcode.delete).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
creator_uid_shortcode_unique: {
|
creator_uid_shortcode_unique: {
|
||||||
creatorUid: mockEmbed.creatorUid,
|
creatorUid: shortCodeWithUser.creatorUid,
|
||||||
id: mockEmbed.id,
|
id: shortCodeWithUser.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -339,53 +249,52 @@ describe('ShortcodeService', () => {
|
|||||||
expect(result).toEqualRight(true);
|
expect(result).toEqualRight(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return SHORTCODE_NOT_FOUND error when Shortcode is invalid and user uid is valid', async () => {
|
test('should return SHORTCODE_NOT_FOUND error when shortcode is invalid and user uid is valid', async () => {
|
||||||
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
|
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
|
||||||
expect(
|
expect(
|
||||||
shortcodeService.revokeShortCode('invalid', 'testuser'),
|
shortcodeService.revokeShortCode('invalid', 'testuser'),
|
||||||
).resolves.toEqualLeft(SHORTCODE_NOT_FOUND);
|
).resolves.toEqualLeft(SHORTCODE_NOT_FOUND);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return SHORTCODE_NOT_FOUND error when Shortcode is valid and user uid is invalid', async () => {
|
test('should return SHORTCODE_NOT_FOUND error when shortcode is valid and user uid is invalid', async () => {
|
||||||
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
|
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
|
||||||
expect(
|
expect(
|
||||||
shortcodeService.revokeShortCode('blablablabla', 'invalidUser'),
|
shortcodeService.revokeShortCode('blablablabla', 'invalidUser'),
|
||||||
).resolves.toEqualLeft(SHORTCODE_NOT_FOUND);
|
).resolves.toEqualLeft(SHORTCODE_NOT_FOUND);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return SHORTCODE_NOT_FOUND error when both Shortcode and user uid are invalid', async () => {
|
test('should return SHORTCODE_NOT_FOUND error when both shortcode and user uid are invalid', async () => {
|
||||||
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
|
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
|
||||||
expect(
|
expect(
|
||||||
shortcodeService.revokeShortCode('invalid', 'invalid'),
|
shortcodeService.revokeShortCode('invalid', 'invalid'),
|
||||||
).resolves.toEqualLeft(SHORTCODE_NOT_FOUND);
|
).resolves.toEqualLeft(SHORTCODE_NOT_FOUND);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should send pubsub message to `shortcode/{uid}/revoked` on successful deletion of Shortcode', async () => {
|
test('should send pubsub message to `shortcode/{uid}/revoked` on successful deletion of shortcode', async () => {
|
||||||
mockPrisma.shortcode.delete.mockResolvedValueOnce(mockEmbed);
|
mockPrisma.shortcode.delete.mockResolvedValueOnce(shortCodeWithUser);
|
||||||
|
|
||||||
const result = await shortcodeService.revokeShortCode(
|
const result = await shortcodeService.revokeShortCode(
|
||||||
mockEmbed.id,
|
shortCodeWithUser.id,
|
||||||
mockEmbed.creatorUid,
|
shortCodeWithUser.creatorUid,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||||
`shortcode/${mockEmbed.creatorUid}/revoked`,
|
`shortcode/${shortCodeWithUser.creatorUid}/revoked`,
|
||||||
{
|
{
|
||||||
id: mockEmbed.id,
|
id: shortCodeWithUser.id,
|
||||||
createdOn: mockEmbed.createdOn,
|
createdOn: shortCodeWithUser.createdOn,
|
||||||
request: JSON.stringify(mockEmbed.request),
|
request: JSON.stringify(shortCodeWithUser.request),
|
||||||
properties: JSON.stringify(mockEmbed.embedProperties),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteUserShortCodes', () => {
|
describe('deleteUserShortCodes', () => {
|
||||||
test('should successfully delete all users Shortcodes with valid user uid', async () => {
|
test('should successfully delete all users shortcodes with valid user uid', async () => {
|
||||||
mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 1 });
|
mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 1 });
|
||||||
|
|
||||||
const result = await shortcodeService.deleteUserShortCodes(
|
const result = await shortcodeService.deleteUserShortCodes(
|
||||||
mockEmbed.creatorUid,
|
shortCodeWithUser.creatorUid,
|
||||||
);
|
);
|
||||||
expect(result).toEqual(1);
|
expect(result).toEqual(1);
|
||||||
});
|
});
|
||||||
@@ -394,180 +303,9 @@ describe('ShortcodeService', () => {
|
|||||||
mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 0 });
|
mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 0 });
|
||||||
|
|
||||||
const result = await shortcodeService.deleteUserShortCodes(
|
const result = await shortcodeService.deleteUserShortCodes(
|
||||||
mockEmbed.creatorUid,
|
shortCodeWithUser.creatorUid,
|
||||||
);
|
);
|
||||||
expect(result).toEqual(0);
|
expect(result).toEqual(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateShortcode', () => {
|
|
||||||
test('should return SHORTCODE_PROPERTIES_NOT_FOUND error when updatedProps in invalid', async () => {
|
|
||||||
const result = await shortcodeService.updateEmbedProperties(
|
|
||||||
mockEmbed.id,
|
|
||||||
user.uid,
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
expect(result).toEqualLeft(SHORTCODE_PROPERTIES_NOT_FOUND);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return SHORTCODE_PROPERTIES_NOT_FOUND error when updatedProps in invalid JSON format', async () => {
|
|
||||||
const result = await shortcodeService.updateEmbedProperties(
|
|
||||||
mockEmbed.id,
|
|
||||||
user.uid,
|
|
||||||
'{kk',
|
|
||||||
);
|
|
||||||
expect(result).toEqualLeft(SHORTCODE_INVALID_PROPERTIES_JSON);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return SHORTCODE_NOT_FOUND error when Shortcode ID is invalid', async () => {
|
|
||||||
mockPrisma.shortcode.update.mockRejectedValue('RecordNotFound');
|
|
||||||
const result = await shortcodeService.updateEmbedProperties(
|
|
||||||
'invalidID',
|
|
||||||
user.uid,
|
|
||||||
'{}',
|
|
||||||
);
|
|
||||||
expect(result).toEqualLeft(SHORTCODE_NOT_FOUND);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should successfully update a Shortcodes with valid inputs', async () => {
|
|
||||||
mockPrisma.shortcode.update.mockResolvedValueOnce({
|
|
||||||
...mockEmbed,
|
|
||||||
embedProperties: '{"foo":"bar"}',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await shortcodeService.updateEmbedProperties(
|
|
||||||
mockEmbed.id,
|
|
||||||
user.uid,
|
|
||||||
'{"foo":"bar"}',
|
|
||||||
);
|
|
||||||
expect(result).toEqualRight({
|
|
||||||
id: mockEmbed.id,
|
|
||||||
createdOn: mockEmbed.createdOn,
|
|
||||||
request: JSON.stringify(mockEmbed.request),
|
|
||||||
properties: JSON.stringify('{"foo":"bar"}'),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should send pubsub message to `shortcode/{uid}/updated` on successful Update of Shortcode', async () => {
|
|
||||||
mockPrisma.shortcode.update.mockResolvedValueOnce({
|
|
||||||
...mockEmbed,
|
|
||||||
embedProperties: '{"foo":"bar"}',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await shortcodeService.updateEmbedProperties(
|
|
||||||
mockEmbed.id,
|
|
||||||
user.uid,
|
|
||||||
'{"foo":"bar"}',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
|
||||||
`shortcode/${mockEmbed.creatorUid}/updated`,
|
|
||||||
{
|
|
||||||
id: mockEmbed.id,
|
|
||||||
createdOn: mockEmbed.createdOn,
|
|
||||||
request: JSON.stringify(mockEmbed.request),
|
|
||||||
properties: JSON.stringify('{"foo":"bar"}'),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('deleteShortcode', () => {
|
|
||||||
test('should return true on successful deletion of Shortcode with valid inputs', async () => {
|
|
||||||
mockPrisma.shortcode.delete.mockResolvedValueOnce(mockEmbed);
|
|
||||||
|
|
||||||
const result = await shortcodeService.deleteShortcode(mockEmbed.id);
|
|
||||||
expect(result).toEqualRight(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return SHORTCODE_NOT_FOUND error when Shortcode is invalid', async () => {
|
|
||||||
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
|
|
||||||
|
|
||||||
expect(shortcodeService.deleteShortcode('invalid')).resolves.toEqualLeft(
|
|
||||||
SHORTCODE_NOT_FOUND,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fetchAllShortcodes', () => {
|
|
||||||
test('should return list of Shortcodes with valid inputs and no cursor', async () => {
|
|
||||||
mockPrisma.shortcode.findMany.mockResolvedValueOnce(
|
|
||||||
shortcodesWithUserEmail,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await shortcodeService.fetchAllShortcodes(
|
|
||||||
{
|
|
||||||
cursor: null,
|
|
||||||
take: 10,
|
|
||||||
},
|
|
||||||
user.email,
|
|
||||||
);
|
|
||||||
expect(result).toEqual(<ShortcodeWithUserEmail[]>[
|
|
||||||
{
|
|
||||||
id: shortcodesWithUserEmail[0].id,
|
|
||||||
request: JSON.stringify(shortcodesWithUserEmail[0].request),
|
|
||||||
properties: JSON.stringify(
|
|
||||||
shortcodesWithUserEmail[0].embedProperties,
|
|
||||||
),
|
|
||||||
createdOn: shortcodesWithUserEmail[0].createdOn,
|
|
||||||
creator: {
|
|
||||||
uid: user.uid,
|
|
||||||
email: user.email,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: shortcodesWithUserEmail[1].id,
|
|
||||||
request: JSON.stringify(shortcodesWithUserEmail[1].request),
|
|
||||||
properties: JSON.stringify(
|
|
||||||
shortcodesWithUserEmail[1].embedProperties,
|
|
||||||
),
|
|
||||||
createdOn: shortcodesWithUserEmail[1].createdOn,
|
|
||||||
creator: {
|
|
||||||
uid: user.uid,
|
|
||||||
email: user.email,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return list of Shortcode with valid inputs and cursor', async () => {
|
|
||||||
mockPrisma.shortcode.findMany.mockResolvedValue([
|
|
||||||
shortcodesWithUserEmail[1],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await shortcodeService.fetchAllShortcodes(
|
|
||||||
{
|
|
||||||
cursor: 'blablabla',
|
|
||||||
take: 10,
|
|
||||||
},
|
|
||||||
user.email,
|
|
||||||
);
|
|
||||||
expect(result).toEqual(<ShortcodeWithUserEmail[]>[
|
|
||||||
{
|
|
||||||
id: shortcodes[1].id,
|
|
||||||
request: JSON.stringify(shortcodes[1].request),
|
|
||||||
properties: JSON.stringify(shortcodes[1].embedProperties),
|
|
||||||
createdOn: shortcodes[1].createdOn,
|
|
||||||
creator: {
|
|
||||||
uid: user.uid,
|
|
||||||
email: user.email,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return an empty array for an invalid cursor', async () => {
|
|
||||||
mockPrisma.shortcode.findMany.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const result = await shortcodeService.fetchAllShortcodes(
|
|
||||||
{
|
|
||||||
cursor: 'invalidcursor',
|
|
||||||
take: 10,
|
|
||||||
},
|
|
||||||
user.email,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
import * as T from 'fp-ts/Task';
|
import * as T from 'fp-ts/Task';
|
||||||
|
import * as O from 'fp-ts/Option';
|
||||||
import * as TO from 'fp-ts/TaskOption';
|
import * as TO from 'fp-ts/TaskOption';
|
||||||
import * as E from 'fp-ts/Either';
|
import * as E from 'fp-ts/Either';
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
import {
|
import { SHORTCODE_INVALID_JSON, SHORTCODE_NOT_FOUND } from 'src/errors';
|
||||||
SHORTCODE_INVALID_PROPERTIES_JSON,
|
|
||||||
SHORTCODE_INVALID_REQUEST_JSON,
|
|
||||||
SHORTCODE_NOT_FOUND,
|
|
||||||
SHORTCODE_PROPERTIES_NOT_FOUND,
|
|
||||||
} from 'src/errors';
|
|
||||||
import { UserDataHandler } from 'src/user/user.data.handler';
|
import { UserDataHandler } from 'src/user/user.data.handler';
|
||||||
import { Shortcode, ShortcodeWithUserEmail } from './shortcode.model';
|
import { Shortcode } from './shortcode.model';
|
||||||
import { Shortcode as DBShortCode } from '@prisma/client';
|
import { Shortcode as DBShortCode } from '@prisma/client';
|
||||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||||
import { UserService } from 'src/user/user.service';
|
import { UserService } from 'src/user/user.service';
|
||||||
@@ -50,14 +46,10 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
|
|||||||
* @param shortcodeInfo Prisma Shortcode type
|
* @param shortcodeInfo Prisma Shortcode type
|
||||||
* @returns GQL Shortcode
|
* @returns GQL Shortcode
|
||||||
*/
|
*/
|
||||||
private cast(shortcodeInfo: DBShortCode): Shortcode {
|
private returnShortCode(shortcodeInfo: DBShortCode): Shortcode {
|
||||||
return <Shortcode>{
|
return <Shortcode>{
|
||||||
id: shortcodeInfo.id,
|
id: shortcodeInfo.id,
|
||||||
request: JSON.stringify(shortcodeInfo.request),
|
request: JSON.stringify(shortcodeInfo.request),
|
||||||
properties:
|
|
||||||
shortcodeInfo.embedProperties != null
|
|
||||||
? JSON.stringify(shortcodeInfo.embedProperties)
|
|
||||||
: null,
|
|
||||||
createdOn: shortcodeInfo.createdOn,
|
createdOn: shortcodeInfo.createdOn,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -102,7 +94,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
|
|||||||
const shortcodeInfo = await this.prisma.shortcode.findFirstOrThrow({
|
const shortcodeInfo = await this.prisma.shortcode.findFirstOrThrow({
|
||||||
where: { id: shortcode },
|
where: { id: shortcode },
|
||||||
});
|
});
|
||||||
return E.right(this.cast(shortcodeInfo));
|
return E.right(this.returnShortCode(shortcodeInfo));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return E.left(SHORTCODE_NOT_FOUND);
|
return E.left(SHORTCODE_NOT_FOUND);
|
||||||
}
|
}
|
||||||
@@ -112,22 +104,14 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
|
|||||||
* Create a new ShortCode
|
* Create a new ShortCode
|
||||||
*
|
*
|
||||||
* @param request JSON string of request details
|
* @param request JSON string of request details
|
||||||
* @param userInfo user UI
|
* @param userUID user UID, if present
|
||||||
* @param properties JSON string of embed properties, if present
|
|
||||||
* @returns Either of ShortCode or error
|
* @returns Either of ShortCode or error
|
||||||
*/
|
*/
|
||||||
async createShortcode(
|
async createShortcode(request: string, userUID: string | null) {
|
||||||
request: string,
|
const shortcodeData = stringToJson(request);
|
||||||
properties: string | null = null,
|
if (E.isLeft(shortcodeData)) return E.left(SHORTCODE_INVALID_JSON);
|
||||||
userInfo: AuthUser,
|
|
||||||
) {
|
|
||||||
const requestData = stringToJson(request);
|
|
||||||
if (E.isLeft(requestData) || !requestData.right)
|
|
||||||
return E.left(SHORTCODE_INVALID_REQUEST_JSON);
|
|
||||||
|
|
||||||
const parsedProperties = stringToJson(properties);
|
const user = await this.userService.findUserById(userUID);
|
||||||
if (E.isLeft(parsedProperties))
|
|
||||||
return E.left(SHORTCODE_INVALID_PROPERTIES_JSON);
|
|
||||||
|
|
||||||
const generatedShortCode = await this.generateUniqueShortCodeID();
|
const generatedShortCode = await this.generateUniqueShortCodeID();
|
||||||
if (E.isLeft(generatedShortCode)) return E.left(generatedShortCode.left);
|
if (E.isLeft(generatedShortCode)) return E.left(generatedShortCode.left);
|
||||||
@@ -135,9 +119,8 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
|
|||||||
const createdShortCode = await this.prisma.shortcode.create({
|
const createdShortCode = await this.prisma.shortcode.create({
|
||||||
data: {
|
data: {
|
||||||
id: generatedShortCode.right,
|
id: generatedShortCode.right,
|
||||||
request: requestData.right,
|
request: shortcodeData.right,
|
||||||
embedProperties: parsedProperties.right ?? undefined,
|
creatorUid: O.isNone(user) ? null : user.value.uid,
|
||||||
creatorUid: userInfo.uid,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,11 +128,11 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
|
|||||||
if (createdShortCode.creatorUid) {
|
if (createdShortCode.creatorUid) {
|
||||||
this.pubsub.publish(
|
this.pubsub.publish(
|
||||||
`shortcode/${createdShortCode.creatorUid}/created`,
|
`shortcode/${createdShortCode.creatorUid}/created`,
|
||||||
this.cast(createdShortCode),
|
this.returnShortCode(createdShortCode),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return E.right(this.cast(createdShortCode));
|
return E.right(this.returnShortCode(createdShortCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,20 +150,20 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
|
|||||||
orderBy: {
|
orderBy: {
|
||||||
createdOn: 'desc',
|
createdOn: 'desc',
|
||||||
},
|
},
|
||||||
skip: args.cursor ? 1 : 0,
|
skip: 1,
|
||||||
take: args.take,
|
take: args.take,
|
||||||
cursor: args.cursor ? { id: args.cursor } : undefined,
|
cursor: args.cursor ? { id: args.cursor } : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchedShortCodes: Shortcode[] = shortCodes.map((code) =>
|
const fetchedShortCodes: Shortcode[] = shortCodes.map((code) =>
|
||||||
this.cast(code),
|
this.returnShortCode(code),
|
||||||
);
|
);
|
||||||
|
|
||||||
return fetchedShortCodes;
|
return fetchedShortCodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a ShortCode created by User of uid
|
* Delete a ShortCode
|
||||||
*
|
*
|
||||||
* @param shortcode ShortCode
|
* @param shortcode ShortCode
|
||||||
* @param uid User Uid
|
* @param uid User Uid
|
||||||
@@ -199,7 +182,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
|
|||||||
|
|
||||||
this.pubsub.publish(
|
this.pubsub.publish(
|
||||||
`shortcode/${deletedShortCodes.creatorUid}/revoked`,
|
`shortcode/${deletedShortCodes.creatorUid}/revoked`,
|
||||||
this.cast(deletedShortCodes),
|
this.returnShortCode(deletedShortCodes),
|
||||||
);
|
);
|
||||||
|
|
||||||
return E.right(true);
|
return E.right(true);
|
||||||
@@ -222,118 +205,4 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
|
|||||||
|
|
||||||
return deletedShortCodes.count;
|
return deletedShortCodes.count;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a Shortcode
|
|
||||||
*
|
|
||||||
* @param shortcodeID ID of Shortcode being deleted
|
|
||||||
* @returns Boolean on successful deletion
|
|
||||||
*/
|
|
||||||
async deleteShortcode(shortcodeID: string) {
|
|
||||||
try {
|
|
||||||
await this.prisma.shortcode.delete({
|
|
||||||
where: {
|
|
||||||
id: shortcodeID,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return E.right(true);
|
|
||||||
} catch (error) {
|
|
||||||
return E.left(SHORTCODE_NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a created Shortcode
|
|
||||||
* @param shortcodeID Shortcode ID
|
|
||||||
* @param uid User Uid
|
|
||||||
* @returns Updated Shortcode
|
|
||||||
*/
|
|
||||||
async updateEmbedProperties(
|
|
||||||
shortcodeID: string,
|
|
||||||
uid: string,
|
|
||||||
updatedProps: string,
|
|
||||||
) {
|
|
||||||
if (!updatedProps) return E.left(SHORTCODE_PROPERTIES_NOT_FOUND);
|
|
||||||
|
|
||||||
const parsedProperties = stringToJson(updatedProps);
|
|
||||||
if (E.isLeft(parsedProperties) || !parsedProperties.right)
|
|
||||||
return E.left(SHORTCODE_INVALID_PROPERTIES_JSON);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updatedShortcode = await this.prisma.shortcode.update({
|
|
||||||
where: {
|
|
||||||
creator_uid_shortcode_unique: {
|
|
||||||
creatorUid: uid,
|
|
||||||
id: shortcodeID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
embedProperties: parsedProperties.right,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.pubsub.publish(
|
|
||||||
`shortcode/${updatedShortcode.creatorUid}/updated`,
|
|
||||||
this.cast(updatedShortcode),
|
|
||||||
);
|
|
||||||
|
|
||||||
return E.right(this.cast(updatedShortcode));
|
|
||||||
} catch (error) {
|
|
||||||
return E.left(SHORTCODE_NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all created ShortCodes
|
|
||||||
*
|
|
||||||
* @param args Pagination arguments
|
|
||||||
* @param userEmail User email
|
|
||||||
* @returns ShortcodeWithUserEmail
|
|
||||||
*/
|
|
||||||
async fetchAllShortcodes(
|
|
||||||
args: PaginationArgs,
|
|
||||||
userEmail: string | null = null,
|
|
||||||
) {
|
|
||||||
const shortCodes = await this.prisma.shortcode.findMany({
|
|
||||||
where: userEmail
|
|
||||||
? {
|
|
||||||
User: {
|
|
||||||
email: userEmail,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
orderBy: {
|
|
||||||
createdOn: 'desc',
|
|
||||||
},
|
|
||||||
skip: args.cursor ? 1 : 0,
|
|
||||||
take: args.take,
|
|
||||||
cursor: args.cursor ? { id: args.cursor } : undefined,
|
|
||||||
include: {
|
|
||||||
User: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchedShortCodes: ShortcodeWithUserEmail[] = shortCodes.map(
|
|
||||||
(code) => {
|
|
||||||
return <ShortcodeWithUserEmail>{
|
|
||||||
id: code.id,
|
|
||||||
request: JSON.stringify(code.request),
|
|
||||||
properties:
|
|
||||||
code.embedProperties != null
|
|
||||||
? JSON.stringify(code.embedProperties)
|
|
||||||
: null,
|
|
||||||
createdOn: code.createdOn,
|
|
||||||
creator: code.User
|
|
||||||
? {
|
|
||||||
uid: code.User.uid,
|
|
||||||
email: code.User.email,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return fetchedShortCodes;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
// Type of data returned from the query to obtain all search results
|
|
||||||
export type SearchQueryReturnType = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
type: 'collection' | 'request';
|
|
||||||
method?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Type of data returned from the query to obtain all parents
|
|
||||||
export type ParentTreeQueryReturnType = {
|
|
||||||
id: string;
|
|
||||||
parentID: string;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
@@ -14,13 +14,6 @@ export class CreateRootTeamCollectionArgs {
|
|||||||
|
|
||||||
@Field({ name: 'title', description: 'Title of the new collection' })
|
@Field({ name: 'title', description: 'Title of the new collection' })
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
@Field({
|
|
||||||
name: 'data',
|
|
||||||
description: 'JSON string representing the collection data',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
data: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ArgsType()
|
@ArgsType()
|
||||||
@@ -33,13 +26,6 @@ export class CreateChildTeamCollectionArgs {
|
|||||||
|
|
||||||
@Field({ name: 'childTitle', description: 'Title of the new collection' })
|
@Field({ name: 'childTitle', description: 'Title of the new collection' })
|
||||||
childTitle: string;
|
childTitle: string;
|
||||||
|
|
||||||
@Field({
|
|
||||||
name: 'data',
|
|
||||||
description: 'JSON string representing the collection data',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
data: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ArgsType()
|
@ArgsType()
|
||||||
@@ -47,14 +33,12 @@ export class RenameTeamCollectionArgs {
|
|||||||
@Field(() => ID, {
|
@Field(() => ID, {
|
||||||
name: 'collectionID',
|
name: 'collectionID',
|
||||||
description: 'ID of the collection',
|
description: 'ID of the collection',
|
||||||
deprecationReason: 'Switch to updateTeamCollection mutation instead',
|
|
||||||
})
|
})
|
||||||
collectionID: string;
|
collectionID: string;
|
||||||
|
|
||||||
@Field({
|
@Field({
|
||||||
name: 'newTitle',
|
name: 'newTitle',
|
||||||
description: 'The updated title of the collection',
|
description: 'The updated title of the collection',
|
||||||
deprecationReason: 'Switch to updateTeamCollection mutation instead',
|
|
||||||
})
|
})
|
||||||
newTitle: string;
|
newTitle: string;
|
||||||
}
|
}
|
||||||
@@ -114,26 +98,3 @@ export class ReplaceTeamCollectionArgs {
|
|||||||
})
|
})
|
||||||
parentCollectionID?: string;
|
parentCollectionID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ArgsType()
|
|
||||||
export class UpdateTeamCollectionArgs {
|
|
||||||
@Field(() => ID, {
|
|
||||||
name: 'collectionID',
|
|
||||||
description: 'ID of the collection',
|
|
||||||
})
|
|
||||||
collectionID: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
name: 'newTitle',
|
|
||||||
description: 'The updated title of the collection',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
newTitle: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
name: 'data',
|
|
||||||
description: 'JSON string representing the collection data',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
data: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
HttpStatus,
|
|
||||||
Param,
|
|
||||||
Query,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { TeamCollectionService } from './team-collection.service';
|
|
||||||
import * as E from 'fp-ts/Either';
|
|
||||||
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
|
||||||
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
|
|
||||||
import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator';
|
|
||||||
import { TeamMemberRole } from '@prisma/client';
|
|
||||||
import { RESTTeamMemberGuard } from 'src/team/guards/rest-team-member.guard';
|
|
||||||
import { throwHTTPErr } from 'src/utils';
|
|
||||||
import { RESTError } from 'src/types/RESTError';
|
|
||||||
import { INVALID_PARAMS } from 'src/errors';
|
|
||||||
|
|
||||||
@UseGuards(ThrottlerBehindProxyGuard)
|
|
||||||
@Controller({ path: 'team-collection', version: '1' })
|
|
||||||
export class TeamCollectionController {
|
|
||||||
constructor(private readonly teamCollectionService: TeamCollectionService) {}
|
|
||||||
|
|
||||||
@Get('search/:teamID')
|
|
||||||
@RequiresTeamRole(
|
|
||||||
TeamMemberRole.VIEWER,
|
|
||||||
TeamMemberRole.EDITOR,
|
|
||||||
TeamMemberRole.OWNER,
|
|
||||||
)
|
|
||||||
@UseGuards(JwtAuthGuard, RESTTeamMemberGuard)
|
|
||||||
async searchByTitle(
|
|
||||||
@Query('searchQuery') searchQuery: string,
|
|
||||||
@Param('teamID') teamID: string,
|
|
||||||
@Query('take') take: string,
|
|
||||||
@Query('skip') skip: string,
|
|
||||||
) {
|
|
||||||
if (!teamID || !searchQuery) {
|
|
||||||
return <RESTError>{
|
|
||||||
message: INVALID_PARAMS,
|
|
||||||
statusCode: HttpStatus.BAD_REQUEST,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await this.teamCollectionService.searchByTitle(
|
|
||||||
searchQuery.trim(),
|
|
||||||
teamID,
|
|
||||||
parseInt(take),
|
|
||||||
parseInt(skip),
|
|
||||||
);
|
|
||||||
if (E.isLeft(res)) throwHTTPErr(res.left);
|
|
||||||
return res.right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user