Compare commits

..

1 Commits

Author SHA1 Message Date
Liyas Thomas
c11b592543 feat: ability to export a single environment 2022-12-05 18:11:05 +05:30
1288 changed files with 34385 additions and 133728 deletions

View File

@@ -5,5 +5,5 @@
"features": {
"ghcr.io/NicoVIII/devcontainer-features/pnpm:1": {}
},
"postCreateCommand": "cp .env.example .env && pnpm i"
"postCreateCommand": "mv .env.example .env && pnpm i"
}

View File

@@ -1,2 +1,104 @@
node_modules
**/*/node_modules
Dockerfile
.vscode
.github
# Created by .ignore support plugin (hsz.mobi)
# Firebase
.firebase
### Node template
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# Nuxt generate
dist
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# IDE / Editor
.idea
# Service worker
sw.*
# Mac OSX
.DS_Store
# Vim swap files
*.swp
# Build data
.hoppscotch
# File explorer
.directory

View File

@@ -1,64 +1,31 @@
#-----------------------Backend Config------------------------------#
# Prisma Config
DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch
# Google Analytics ID
VITE_GA_ID=UA-61422507-4
# Auth Tokens Config
JWT_SECRET="secret1233"
TOKEN_SALT_COMPLEXITY=10
MAGIC_LINK_TOKEN_VALIDITY= 3
REFRESH_TOKEN_VALIDITY="604800000" # Default validity is 7 days (604800000 ms) in ms
ACCESS_TOKEN_VALIDITY="86400000" # Default validity is 1 day (86400000 ms) in ms
SESSION_SECRET='add some secret here'
# Hoppscotch App Domain Config
REDIRECT_URL="http://localhost:3000"
WHITELISTED_ORIGINS="http://localhost:3170,http://localhost:3000,http://localhost:3100"
VITE_ALLOWED_AUTH_PROVIDERS=GOOGLE,GITHUB,MICROSOFT,EMAIL
# Google Auth Config
GOOGLE_CLIENT_ID="************************************************"
GOOGLE_CLIENT_SECRET="************************************************"
GOOGLE_CALLBACK_URL="http://localhost:3170/v1/auth/google/callback"
GOOGLE_SCOPE="email,profile"
# Github Auth Config
GITHUB_CLIENT_ID="************************************************"
GITHUB_CLIENT_SECRET="************************************************"
GITHUB_CALLBACK_URL="http://localhost:3170/v1/auth/github/callback"
GITHUB_SCOPE="user:email"
# Microsoft Auth Config
MICROSOFT_CLIENT_ID="************************************************"
MICROSOFT_CLIENT_SECRET="************************************************"
MICROSOFT_CALLBACK_URL="http://localhost:3170/v1/auth/microsoft/callback"
MICROSOFT_SCOPE="user.read"
MICROSOFT_TENANT="common"
# Mailer config
MAILER_SMTP_URL="smtps://user@domain.com:pass@smtp.domain.com"
MAILER_ADDRESS_FROM='"From Name Here" <from@example.com>'
# Rate Limit Config
RATE_LIMIT_TTL=60 # In seconds
RATE_LIMIT_MAX=100 # Max requests per IP
#-----------------------Frontend Config------------------------------#
# Google Tag Manager ID
VITE_GTM_ID=GTM-NMKVBMV
# Firebase config
VITE_API_KEY=AIzaSyCMsFreESs58-hRxTtiqQrIcimh4i1wbsM
VITE_AUTH_DOMAIN=postwoman-api.firebaseapp.com
VITE_DATABASE_URL=https://postwoman-api.firebaseio.com
VITE_PROJECT_ID=postwoman-api
VITE_STORAGE_BUCKET=postwoman-api.appspot.com
VITE_MESSAGING_SENDER_ID=421993993223
VITE_APP_ID=1:421993993223:web:ec0baa8ee8c02ffa1fc6a2
VITE_MEASUREMENT_ID=G-BBJ3R80PJT
# Base URLs
VITE_BASE_URL=http://localhost:3000
VITE_SHORTCODE_BASE_URL=http://localhost:3000
VITE_ADMIN_URL=http://localhost:3100
VITE_BASE_URL=https://hoppscotch.io
VITE_SHORTCODE_BASE_URL=https://hopp.sh
# Backend URLs
VITE_BACKEND_GQL_URL=http://localhost:3170/graphql
VITE_BACKEND_WS_URL=ws://localhost:3170/graphql
VITE_BACKEND_API_URL=http://localhost:3170/v1
VITE_BACKEND_GQL_URL=https://api.hoppscotch.io/graphql
VITE_BACKEND_WS_URL=wss://api.hoppscotch.io/graphql
# Terms Of Service And Privacy Policy Links (Optional)
VITE_APP_TOS_LINK=https://docs.hoppscotch.io/support/terms
VITE_APP_PRIVACY_POLICY_LINK=https://docs.hoppscotch.io/support/privacy
# Sentry (Optional)
# VITE_SENTRY_DSN: <Sentry DSN here>
# VITE_SENTRY_ENVIRONMENT: <Sentry environment value here>
# VITE_SENTRY_RELEASE_TAG: <Sentry release tag here (for release monitoring)>
# Set to `true` for subpath based access
ENABLE_SUBPATH_BASED_ACCESS=false
# Proxyscotch Access Token (Optional)
# VITE_PROXYSCOTCH_ACCESS_TOKEN: <Token Set In Proxyscotch Server>

View File

@@ -1,63 +1,72 @@
name: "CodeQL analysis"
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [main]
branches: [ main ]
pull_request:
branches: [main]
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
# ┌───────────── minute (0 - 59)
# │ ┌───────────── hour (0 - 23)
# │ │ ┌───────────── day of the month (1 - 31)
# │ │ │ ┌───────────── month (1 - 12 or JAN-DEC)
# │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT)
# │ │ │ │ │
# │ │ │ │ │
# │ │ │ │ │
# * * * * *
- cron: '30 1 * * 0'
- cron: '39 7 * * 2'
jobs:
analyze:
name: Analyze
# CodeQL runs on ubuntu-latest, windows-latest, and macos-latest
runs-on: ubuntu-latest
permissions:
# required for all workflows
security-events: write
# only required for workflows in private repositories
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
# Run extended queries including queries using machine learning
queries: security-extended
languages: ${{ matrix.language }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
# Run extended queries including queries using machine learning
queries: security-extended
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
# If this step fails, then you should remove it and run the build manually (see below).
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following
# three lines and modify them (or add more) to build your code if your
# project uses a compiled language
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

48
.github/workflows/deploy-netlify.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: Deploy to Netlify
on:
push:
branches: [main]
jobs:
build:
name: Push build files to Netlify
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Setup Environment
run: mv .env.example .env
- name: Setup and run pnpm install
uses: pnpm/action-setup@v2.2.4
with:
version: 7
run_install: true
- name: Build Site
env:
VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_SENTRY_ENVIRONMENT: production
VITE_SENTRY_RELEASE_TAG: ${{ github.sha }}
run: pnpm run generate
# Deploy the production site with netlify-cli
- name: Deploy to Netlify (production)
run: npx netlify-cli deploy --dir=packages/hoppscotch-web/dist --prod
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_PRODUCTION_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
- name: Create Sentry Release
uses: getsentry/action-release@v1
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
with:
environment: production
ignore_missing: true
ignore_empty: true
version: ${{ github.sha }}

View File

@@ -0,0 +1,60 @@
name: Deploy to Preview Netlify
on:
pull_request:
branches:
- main
jobs:
build:
name: Push build files to Netlify
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Setup and run pnpm install
uses: pnpm/action-setup@v2.2.4
env:
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
with:
version: 7
run_install: true
- name: Build Site
env:
VITE_GA_ID: ${{ secrets.STAGING_GA_ID }}
VITE_GTM_ID: ${{ secrets.STAGING_GTM_ID }}
VITE_API_KEY: ${{ secrets.STAGING_FB_API_KEY }}
VITE_AUTH_DOMAIN: ${{ secrets.STAGING_FB_AUTH_DOMAIN }}
VITE_DATABASE_URL: ${{ secrets.STAGING_FB_DATABASE_URL }}
VITE_PROJECT_ID: ${{ secrets.STAGING_FB_PROJECT_ID }}
VITE_STORAGE_BUCKET: ${{ secrets.STAGING_FB_STORAGE_BUCKET }}
VITE_MESSAGING_SENDER_ID: ${{ secrets.STAGING_FB_MESSAGING_SENDER_ID }}
VITE_APP_ID: ${{ secrets.STAGING_FB_APP_ID }}
VITE_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
VITE_BACKEND_WS_URL: ${{ secrets.STAGING_BACKEND_WS_URL }}
VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_SENTRY_RELEASE_TAG: ${{ github.sha }}
VITE_SENTRY_ENVIRONMENT: staging
run: pnpm run generate
# Deploy the preview site with netlify-cli
- name: Deploy to Netlify (preview)
run: npx netlify-cli deploy --dir=packages/hoppscotch-web/dist --alias=preview
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_STAGING_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
- name: Create Sentry Release
uses: getsentry/action-release@v1
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
with:
environment: preview
ignore_missing: true
ignore_empty: true
version: ${{ github.sha }}

21
.github/workflows/deploy-prod.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: Deploy to Live Channel
on:
push:
branches:
- main
jobs:
deploy_live_website:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Deploy to Firebase (production)
uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: '${{ secrets.GITHUB_TOKEN }}'
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_POSTWOMAN_API }}'
channelId: live
projectId: postwoman-api

View File

@@ -0,0 +1,60 @@
name: Deploy to Staging Netlify
on:
push:
# TODO: Migrate to staging branch only
branches: [main]
jobs:
build:
name: Push build files to Netlify
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Setup and run pnpm install
uses: pnpm/action-setup@v2.2.4
env:
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
with:
version: 7
run_install: true
- name: Build Site
env:
VITE_GA_ID: ${{ secrets.STAGING_GA_ID }}
VITE_GTM_ID: ${{ secrets.STAGING_GTM_ID }}
VITE_API_KEY: ${{ secrets.STAGING_FB_API_KEY }}
VITE_AUTH_DOMAIN: ${{ secrets.STAGING_FB_AUTH_DOMAIN }}
VITE_DATABASE_URL: ${{ secrets.STAGING_FB_DATABASE_URL }}
VITE_PROJECT_ID: ${{ secrets.STAGING_FB_PROJECT_ID }}
VITE_STORAGE_BUCKET: ${{ secrets.STAGING_FB_STORAGE_BUCKET }}
VITE_MESSAGING_SENDER_ID: ${{ secrets.STAGING_FB_MESSAGING_SENDER_ID }}
VITE_APP_ID: ${{ secrets.STAGING_FB_APP_ID }}
VITE_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
VITE_BACKEND_WS_URL: ${{ secrets.STAGING_BACKEND_WS_URL }}
VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_SENTRY_RELEASE_TAG: ${{ github.sha }}
VITE_SENTRY_ENVIRONMENT: staging
run: pnpm run generate
# Deploy the staging site with netlify-cli
- name: Deploy to Netlify (staging)
run: npx netlify-cli deploy --dir=packages/hoppscotch-web/dist --prod
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_STAGING_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
- name: Create Sentry Release
uses: getsentry/action-release@v1
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
with:
environment: staging
ignore_missing: true
ignore_empty: true
version: ${{ github.sha }}

46
.github/workflows/publish-docker.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: Publish Docker image
on:
push:
branches: [main]
release:
types: [published]
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: hoppscotch/hoppscotch
flavor: |
latest=true
prefix=
suffix=
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

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

View File

@@ -2,13 +2,12 @@ name: Node.js CI
on:
push:
branches: [main, staging, "release/**"]
branches: [main]
pull_request:
branches: [main, staging, "release/**"]
branches: [main]
jobs:
test:
name: Test
build:
runs-on: ubuntu-latest
strategy:
@@ -16,22 +15,22 @@ jobs:
node-version: ["lts/*"]
steps:
- name: Checkout
- name: Checkout Repository
uses: actions/checkout@v3
- name: Setup environment
- name: Setup Environment
run: mv .env.example .env
- name: Setup pnpm
- name: Setup and run pnpm install
uses: pnpm/action-setup@v2.2.4
with:
version: 8
version: 7
run_install: true
- name: Setup node
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
node-version: ${{ matrix.node-version }}
cache: pnpm
- name: Run tests

View File

@@ -1,42 +0,0 @@
name: Deploy to Netlify (ui)
on:
push:
branches: [main]
# run this workflow only if an update is made to the ui package
paths:
- "packages/hoppscotch-ui/**"
workflow_dispatch:
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup environment
run: mv .env.example .env
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
with:
version: 8
run_install: true
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Build site
run: pnpm run generate-ui
# Deploy the ui site with netlify-cli
- name: Deploy to Netlify (ui)
run: npx netlify-cli@15.11.0 deploy --dir=packages/hoppscotch-ui/.histoire/dist --prod
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_UI_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}

8
.gitignore vendored
View File

@@ -81,7 +81,10 @@ web_modules/
# dotenv environment variable files
.env
.env.*
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
@@ -168,6 +171,3 @@ tests/*/videos
# PNPM
.pnpm-store
# GQL SDL generated for the frontends
gql-gen/

1
.npmrc
View File

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

View File

@@ -1,8 +1,3 @@
module.exports = {
semi: false,
trailingComma: "es5",
singleQuote: false,
printWidth: 80,
useTabs: false,
tabWidth: 2
semi: false
}

14
.vscode/extensions.json vendored Normal file
View 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"
]
}

View File

@@ -1,30 +0,0 @@
# CODEOWNERS is prioritized from bottom to top
# If none of the below matched
* @AndrewBastin @liyasthomas
# Packages
/packages/codemirror-lang-graphql/ @AndrewBastin
/packages/hoppscotch-cli/ @AndrewBastin
/packages/hoppscotch-common/ @amk-dev @AndrewBastin
/packages/hoppscotch-data/ @AndrewBastin
/packages/hoppscotch-js-sandbox/ @AndrewBastin
/packages/hoppscotch-ui/ @anwarulislam
/packages/hoppscotch-web/ @amk-dev
/packages/hoppscotch-selfhost-web/ @amk-dev
/packages/hoppscotch-sh-admin/ @JoelJacobStephen
/packages/hoppscotch-backend/ @ankitsridhar16 @balub
# Sections within Hoppscotch Common
/packages/hoppscotch-common/src/components @anwarulislam
/packages/hoppscotch-common/src/components/collections @nivedin @amk-dev
/packages/hoppscotch-common/src/components/environments @nivedin @amk-dev
/packages/hoppscotch-common/src/composables @amk-dev
/packages/hoppscotch-common/src/modules @AndrewBastin @amk-dev
/packages/hoppscotch-common/src/pages @AndrewBastin @amk-dev
/packages/hoppscotch-common/src/newstore @AndrewBastin @amk-dev
README.md @liyasthomas
# The lockfile has no owner
pnpm-lock.yaml

View File

@@ -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
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
@@ -22,17 +22,17 @@ community include:
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
@@ -82,15 +82,15 @@ behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
@@ -106,27 +106,23 @@ Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**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.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
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].
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[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
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM node:lts-alpine
LABEL maintainer="Hoppscotch (support@hoppscotch.io)"
# Add git as the prebuild target requires it to parse version information
RUN apk add --no-cache --virtual .gyp \
python3 \
make \
g++
# Create app directory
WORKDIR /app
ADD . /app/
COPY . .
RUN npm install -g pnpm
RUN mv .env.example .env
RUN pnpm i --unsafe-perm=true
ENV HOST 0.0.0.0
EXPOSE 3000
RUN pnpm run generate
CMD ["pnpm", "run", "start"]

228
README.md
View File

@@ -2,18 +2,23 @@
<a href="https://hoppscotch.io">
<img
src="https://avatars.githubusercontent.com/u/56705483"
alt="Hoppscotch"
alt="Hoppscotch Logo"
height="64"
/>
</a>
<h3>
<br />
<p>
<h3>
<b>
Hoppscotch
</b>
</h3>
</p>
<p>
<b>
Hoppscotch
Open source API development ecosystem
</b>
</h3>
<b>
Open Source API Development Ecosystem
</b>
</p>
<p>
[![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen?logo=github)](CODE_OF_CONDUCT.md) [![Website](https://img.shields.io/website?url=https%3A%2F%2Fhoppscotch.io&logo=hoppscotch)](https://hoppscotch.io) [![Tests](https://github.com/hoppscotch/hoppscotch/actions/workflows/tests.yml/badge.svg)](https://github.com/hoppscotch/hoppscotch/actions) [![Tweet](https://img.shields.io/twitter/url?url=https%3A%2F%2Fhoppscotch.io%2F)](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>
<br />
<p>
<a href="https://hoppscotch.io">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/hoppscotch-common/public/images/banner-dark.png">
<source media="(prefers-color-scheme: light)" srcset="./packages/hoppscotch-common/public/images/banner-light.png">
<img alt="Hoppscotch" src="./packages/hoppscotch-common/public/images/banner-dark.png">
</picture>
<a href="https://hoppscotch.io/#gh-light-mode-only" target="_blank">
<img
src="./packages/hoppscotch-app/public/images/banner-light.png"
alt="Hoppscotch"
width="100%"
/>
</a>
<a href="https://hoppscotch.io/#gh-dark-mode-only" target="_blank">
<img
src="./packages/hoppscotch-app/public/images/banner-dark.png"
alt="Hoppscotch"
width="100%"
/>
</a>
</p>
</div>
_We highly recommend you take a look at the [**Hoppscotch Documentation**](https://docs.hoppscotch.io) to learn more about the app._
#### **Support**
[![Chat on Discord](https://img.shields.io/badge/chat-Discord-7289DA?logo=discord)](https://hoppscotch.io/discord) [![Chat on Telegram](https://img.shields.io/badge/chat-Telegram-2CA5E0?logo=telegram)](https://hoppscotch.io/telegram) [![Discuss on GitHub](https://img.shields.io/badge/discussions-GitHub-333333?logo=github)](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.
⚡️ **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
- `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
- `<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
- Choose accent colors: Green, Teal, Blue, Indigo, Purple, Yellow, Orange, Red, and Pink
**Theming**
- 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
_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
- 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.
🌩 **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.
@@ -115,7 +127,7 @@ _Customized themes are synced with your cloud/local session._
- OAuth 2.0
- 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.
@@ -125,14 +137,14 @@ _Customized themes are synced with your cloud/local session._
- FormData, JSON, and many more
- 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
- Download the response as a file
- Copy response to clipboard
- Download response as a file
- 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.
@@ -140,32 +152,7 @@ _Customized themes are synced with your cloud/local session._
- Nested folders
- Export and import as a file or GitHub gist
_Collections are synced with your 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)**
_Collections are synced with cloud / local session storage_
🌐 **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
- 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/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/features/shortcuts)**
🌎 **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://raw.github.com/alrra/browser-logos/master/src/firefox/firefox_16x16.png) **Firefox**](https://addons.mozilla.org/en-US/firefox/addon/hoppscotch) &nbsp;|&nbsp; [![Chrome](https://raw.github.com/alrra/browser-logos/master/src/chrome/chrome_16x16.png) **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
- Google
- Microsoft
- Email
- SSO (Single Sign-On)[^EE]
**🔄 Synchronize your data:** Handoff to continue tasks on your other devices.
**Synchronize your data**
- Workspaces
- History
- Collections
- Environments
- 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
- Filter response headers
@@ -206,7 +222,7 @@ Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) f
- Set environment variables
- 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
- Initialize through the pre-request script
@@ -225,31 +241,22 @@ Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) f
</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.
- Entries are separated by newline
- Keys and values are separated by `:`
- Prepend `#` to any row you want to add but keep disabled
🎛️ **Admin dashboard:** Manage your team and invite members.
- 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://raw.github.com/alrra/browser-logos/master/src/firefox/firefox_16x16.png) **Firefox**](https://addons.mozilla.org/en-US/firefox/addon/hoppscotch) &nbsp;|&nbsp; [![Chrome](https://raw.github.com/alrra/browser-logos/master/src/chrome/chrome_16x16.png) **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).**
**For more features, please read our [documentation](https://docs.hoppscotch.io).**
## **Demo**
@@ -261,9 +268,56 @@ _Add-ons are developed and maintained under **[Hoppscotch Organization](https://
2. Click "Send" to simulate the request
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**
Follow our [self-hosting documentation](https://docs.hoppscotch.io/documentation/self-host/getting-started) to get started with the development environment.
0. Update [`.env.example`](https://github.com/hoppscotch/hoppscotch/blob/main/.env.example) file found in the root of repository with your own keys and rename it to `.env`.
_Sample keys only work with the [production build](https://hoppscotch.io)._
### Browser-based development environment
- [GitHub codespace](https://docs.github.com/en/codespaces/developing-in-codespaces/creating-a-codespace)
- [Gitpod](https://gitpod.io/#https://github.com/hoppscotch/hoppscotch)
### Local development environment
1. [Clone this repo](https://help.github.com/en/articles/cloning-a-repository) with git.
2. Install pnpm using npm by running `npm install -g pnpm`.
3. Install dependencies by running `pnpm install` within the directory that you cloned (probably `hoppscotch`).
4. Start the development server with `pnpm run dev`.
5. Open the development site by going to [`http://localhost:3000`](http://localhost:3000) in your browser.
### Docker compose
1. [Clone this repo](https://help.github.com/en/articles/cloning-a-repository) with git.
2. Run `docker-compose up` within the directory that you cloned (probably `hoppscotch`).
3. Open the development site by going to [`http://localhost:3000`](http://localhost:3000) in your browser.
## **Docker**
**Official container** &nbsp; [![hoppscotch/hoppscotch](https://img.shields.io/docker/pulls/hoppscotch/hoppscotch?style=social)](https://hub.docker.com/r/hoppscotch/hoppscotch)
```bash
docker run --rm --name hoppscotch -p 3000:3000 hoppscotch/hoppscotch:latest
```
## **Releasing**
1. [Clone this repo](https://help.github.com/en/articles/cloning-a-repository) with git.
2. Install pnpm using npm by running `npm install -g pnpm`.
3. Install dependencies by running `pnpm install` within the directory that you cloned (probably `hoppscotch`).
4. Update [`.env.example`](https://github.com/hoppscotch/hoppscotch/blob/main/.env.example) file found in the root of repository with your own keys and rename it to `.env`.
5. Build the release files with `pnpm run generate`.
6. Find the built project in `packages/hoppscotch-app/dist`. Host these files on any [static hosting servers](https://www.pluralsight.com/blog/software-development/where-to-host-your-jamstack-site).
## **Contributing**
@@ -281,7 +335,7 @@ See the [`CHANGELOG`](CHANGELOG.md) file for details.
## **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">
<a href="https://github.com/hoppscotch/hoppscotch/graphs/contributors">
@@ -293,6 +347,4 @@ This project owes its existence to the collective efforts of all those who contr
## **License**
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).
This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [`LICENSE`](LICENSE) file for details.

View File

@@ -2,9 +2,8 @@
This document outlines security procedures and general policies for the Hoppscotch project.
- [Security Policy](#security-policy)
- [Reporting a security vulnerability](#reporting-a-security-vulnerability)
- [Incident response process](#incident-response-process)
1. [Reporting a security vulnerability](#reporting-a-security-vulnerability)
3. [Incident response process](#incident-response-process)
## Reporting a security vulnerability

View File

@@ -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:
1. **[Fork the repository](https://github.com/hoppscotch/hoppscotch/fork).**
2. **Checkout the `main` branch for latest translations.**
3. **Create a new branch for your translation with base branch `main`.**
2. **Checkout the `i18n` branch for latest translations.**
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.**
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.**
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.**
_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.
## Updating a translation
### 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

View File

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

View File

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

View File

@@ -1,73 +0,0 @@
#!/usr/local/bin/node
// @ts-check
import { execSync, spawn } from "child_process"
import fs from "fs"
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.log("error")
console.log(stuff)
})
return childProcess
}
const envFileContent = Object.entries(process.env)
.filter(([env]) => env.startsWith("VITE_"))
.map(([env, val]) => `${env}=${
(val.startsWith("\"") && val.endsWith("\""))
? val
: `"${val}"`
}`)
.join("\n")
fs.writeFileSync("build.env", envFileContent)
execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`)
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/${caddyFileName}`, "--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)
})

View File

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

View File

@@ -1,155 +1,23 @@
# To make it easier to self-host, we have a preset docker compose config that also
# has a container with a Postgres instance running.
# You can tweak around this file to match your instances
version: "3.7"
services:
# This service runs the backend app in the port 3170
hoppscotch-backend:
container_name: hoppscotch-backend
web:
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
- "./.hoppscotch:/app/.hoppscotch"
- "./assets:/app/assets"
- "./directives:/app/directives"
- "./layouts:/app/layouts"
- "./middleware:/app/middleware"
- "./pages:/app/pages"
- "./plugins:/app/plugins"
- "./static:/app/static"
- "./store:/app/store"
- "./components:/app/components"
- "./helpers:/app/helpers"
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:
dockerfile: packages/hoppscotch-backend/Dockerfile
context: .
target: prod
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=3000
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:
- "3170:3000"
hoppscotch-old-app:
container_name: hoppscotch-old-app
build:
dockerfile: packages/hoppscotch-selfhost-web/Dockerfile
context: .
env_file:
- ./.env
depends_on:
- hoppscotch-old-backend
ports:
- "3000:8080"
hoppscotch-old-sh-admin:
container_name: hoppscotch-old-sh-admin
build:
dockerfile: packages/hoppscotch-sh-admin/Dockerfile
context: .
env_file:
- ./.env
depends_on:
- hoppscotch-old-backend
ports:
- "3100:8080"
HOST: 0.0.0.0
command: "pnpm run dev"

View File

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

View File

@@ -10,7 +10,7 @@
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "SAMEORIGIN"
X-Frame-Options = "DENY"
X-XSS-Protection = "1; mode=block"
[[redirects]]

View File

@@ -9,39 +9,25 @@
"preinstall": "npx only-allow pnpm",
"prepare": "husky install",
"dev": "pnpm -r do-dev",
"gen-gql": "cross-env GQL_SCHEMA_EMIT_LOCATION='../../../gql-gen/backend-schema.gql' pnpm -r generate-gql-sdl",
"generate": "pnpm -r do-build-prod",
"start": "http-server packages/hoppscotch-selfhost-web/dist -p 3000",
"start": "http-server packages/hoppscotch-web/dist -p 3000",
"lint": "pnpm -r do-lint",
"typecheck": "pnpm -r do-typecheck",
"lintfix": "pnpm -r do-lintfix",
"pre-commit": "pnpm -r do-lint && pnpm -r do-typecheck",
"test": "pnpm -r do-test",
"generate-ui": "pnpm -r do-build-ui"
"test": "pnpm -r do-test"
},
"workspaces": [
"./packages/*"
],
"devDependencies": {
"@commitlint/cli": "16.3.0",
"@commitlint/config-conventional": "16.2.4",
"@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"
"dependencies": {
"husky": "^7.0.4",
"lint-staged": "^12.3.8"
},
"pnpm": {
"overrides": {
"vue": "3.3.9"
},
"packageExtensions": {
"httpsnippet@3.0.1": {
"peerDependencies": {
"ajv": "6.12.3"
}
}
}
"devDependencies": {
"@commitlint/cli": "^16.2.3",
"@commitlint/config-conventional": "^16.2.1",
"@types/node": "^17.0.24",
"http-server": "^14.1.1"
}
}

View File

@@ -17,16 +17,16 @@
"types": "dist/index.d.ts",
"sideEffects": false,
"dependencies": {
"@codemirror/language": "6.10.1",
"@lezer/highlight": "1.2.0",
"@lezer/lr": "1.3.14"
"@codemirror/language": "^6.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.2.0"
},
"devDependencies": {
"@lezer/generator": "1.5.1",
"mocha": "9.2.2",
"rollup": "3.29.4",
"rollup-plugin-dts": "6.0.2",
"rollup-plugin-ts": "3.4.5",
"typescript": "5.2.2"
"@lezer/generator": "^1.1.0",
"mocha": "^9.2.2",
"rollup": "^2.70.2",
"rollup-plugin-dts": "^4.2.1",
"rollup-plugin-ts": "^2.0.7",
"typescript": "^4.6.3"
}
}
}

View File

@@ -1 +0,0 @@
./node_modules

View File

@@ -1,27 +0,0 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
"no-empty-function": "off",
"@typescript-eslint/no-empty-function": "error"
},
};

View File

@@ -1,43 +0,0 @@
# compiled output
/dist
/node_modules
.vscode
.env
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# Generated artifacts (GQL Schema SDL generation etc.)
gen/

View File

@@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View File

@@ -1,38 +0,0 @@
FROM node:18.8.0 AS builder
WORKDIR /usr/src/app
# # Install pnpm
RUN npm i -g pnpm
COPY .env .
COPY pnpm-lock.yaml .
RUN pnpm fetch
ENV APP_PORT=${PORT}
ENV DB_URL=${DATABASE_URL}
# # PNPM package install
COPY ./packages/hoppscotch-backend .
RUN pnpm i --filter hoppscotch-backend
# Prisma bits
RUN pnpm exec prisma generate
FROM builder AS dev
ENV PRODUCTION="false"
CMD ["pnpm", "run", "start:dev"]
EXPOSE 3170
FROM builder AS prod
ENV PRODUCTION="true"
CMD ["pnpm", "run", "start:prod"]
EXPOSE 3170

View File

@@ -1,3 +0,0 @@
:80 :3170 {
reverse_proxy localhost:8080
}

View File

@@ -1 +0,0 @@
import '@relmify/jest-fp-ts';

View File

@@ -1 +0,0 @@
require('@relmify/jest-fp-ts');

View File

@@ -1,11 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"assets": [
"**/*.hbs"
],
"watchAssets": true
}
}

View File

@@ -1,124 +0,0 @@
{
"name": "hoppscotch-backend",
"version": "2024.3.0",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"generate-gql-sdl": "cross-env GQL_SCHEMA_EMIT_LOCATION='../../../gql-gen/backend-schema.gql' GENERATE_GQL_SCHEMA=true WHITELISTED_ORIGINS='' nest start",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"postinstall": "prisma generate && pnpm run generate-gql-sdl",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"do-test": "pnpm run test"
},
"dependencies": {
"@apollo/server": "4.9.5",
"@nestjs-modules/mailer": "1.9.1",
"@nestjs/apollo": "12.0.9",
"@nestjs/common": "10.2.7",
"@nestjs/config": "3.1.1",
"@nestjs/core": "10.2.7",
"@nestjs/graphql": "12.0.9",
"@nestjs/jwt": "10.1.1",
"@nestjs/passport": "10.0.2",
"@nestjs/platform-express": "10.2.7",
"@nestjs/schedule": "4.0.1",
"@nestjs/throttler": "5.0.1",
"@prisma/client": "5.8.1",
"argon2": "0.30.3",
"bcrypt": "5.1.0",
"cookie": "0.5.0",
"cookie-parser": "1.4.6",
"cron": "3.1.6",
"express": "4.18.2",
"express-session": "1.17.3",
"fp-ts": "2.13.1",
"graphql": "16.8.1",
"graphql-query-complexity": "0.12.0",
"graphql-redis-subscriptions": "2.6.0",
"graphql-subscriptions": "2.0.0",
"handlebars": "4.7.7",
"io-ts": "2.2.16",
"luxon": "3.2.1",
"nodemailer": "6.9.1",
"passport": "0.6.0",
"passport-github2": "0.1.12",
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.1",
"passport-local": "1.0.0",
"passport-microsoft": "1.0.0",
"posthog-node": "3.6.3",
"prisma": "5.8.1",
"reflect-metadata": "0.1.13",
"rimraf": "3.0.2",
"rxjs": "7.6.0"
},
"devDependencies": {
"@nestjs/cli": "10.2.1",
"@nestjs/schematics": "10.0.3",
"@nestjs/testing": "10.2.7",
"@relmify/jest-fp-ts": "2.0.2",
"@types/argon2": "0.15.0",
"@types/bcrypt": "5.0.0",
"@types/cookie": "0.5.1",
"@types/cookie-parser": "1.4.3",
"@types/express": "4.17.14",
"@types/jest": "29.4.0",
"@types/luxon": "3.2.0",
"@types/node": "18.11.10",
"@types/nodemailer": "6.4.7",
"@types/passport-github2": "1.2.5",
"@types/passport-google-oauth20": "2.0.11",
"@types/passport-jwt": "3.0.8",
"@types/passport-microsoft": "0.0.0",
"@types/supertest": "2.0.12",
"@typescript-eslint/eslint-plugin": "5.45.0",
"@typescript-eslint/parser": "5.45.0",
"cross-env": "7.0.3",
"eslint": "8.29.0",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-prettier": "4.2.1",
"jest": "29.4.1",
"jest-mock-extended": "3.0.1",
"jwt": "link:@types/nestjs/jwt",
"prettier": "2.8.4",
"source-map-support": "0.5.21",
"supertest": "6.3.2",
"ts-jest": "29.0.5",
"ts-loader": "9.4.2",
"ts-node": "10.9.1",
"tsconfig-paths": "4.1.1",
"typescript": "4.9.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"setupFilesAfterEnv": [
"../jest.setup.js"
],
"preset": "ts-jest",
"clearMocks": true,
"collectCoverage": true,
"coverageDirectory": "coverage",
"coverageProvider": "v8",
"rootDir": "src",
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
}
}
}

View File

@@ -1,270 +0,0 @@
-- CreateEnum
CREATE TYPE "ReqType" AS ENUM ('REST', 'GQL');
-- CreateEnum
CREATE TYPE "TeamMemberRole" AS ENUM ('OWNER', 'VIEWER', 'EDITOR');
-- CreateTable
CREATE TABLE "Team" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TeamMember" (
"id" TEXT NOT NULL,
"role" "TeamMemberRole" NOT NULL,
"userUid" TEXT NOT NULL,
"teamID" TEXT NOT NULL,
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TeamInvitation" (
"id" TEXT NOT NULL,
"teamID" TEXT NOT NULL,
"creatorUid" TEXT NOT NULL,
"inviteeEmail" TEXT NOT NULL,
"inviteeRole" "TeamMemberRole" NOT NULL,
CONSTRAINT "TeamInvitation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TeamCollection" (
"id" TEXT NOT NULL,
"parentID" TEXT,
"teamID" TEXT NOT NULL,
"title" TEXT NOT NULL,
"orderIndex" INTEGER NOT NULL,
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedOn" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TeamCollection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TeamRequest" (
"id" TEXT NOT NULL,
"collectionID" TEXT NOT NULL,
"teamID" TEXT NOT NULL,
"title" TEXT NOT NULL,
"request" JSONB NOT NULL,
"orderIndex" INTEGER NOT NULL,
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedOn" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TeamRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Shortcode" (
"id" TEXT NOT NULL,
"request" JSONB NOT NULL,
"creatorUid" TEXT,
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Shortcode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TeamEnvironment" (
"id" TEXT NOT NULL,
"teamID" TEXT NOT NULL,
"name" TEXT NOT NULL,
"variables" JSONB NOT NULL,
CONSTRAINT "TeamEnvironment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"uid" TEXT NOT NULL,
"displayName" TEXT,
"email" TEXT,
"photoURL" TEXT,
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"refreshToken" TEXT,
"currentRESTSession" JSONB,
"currentGQLSession" JSONB,
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("uid")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"providerRefreshToken" TEXT,
"providerAccessToken" TEXT,
"providerScope" TEXT,
"loggedIn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"deviceIdentifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"userUid" TEXT NOT NULL,
"expiresOn" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "UserSettings" (
"id" TEXT NOT NULL,
"userUid" TEXT NOT NULL,
"properties" JSONB NOT NULL,
"updatedOn" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserHistory" (
"id" TEXT NOT NULL,
"userUid" TEXT NOT NULL,
"reqType" "ReqType" NOT NULL,
"request" JSONB NOT NULL,
"responseMetadata" JSONB NOT NULL,
"isStarred" BOOLEAN NOT NULL,
"executedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserHistory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserEnvironment" (
"id" TEXT NOT NULL,
"userUid" TEXT NOT NULL,
"name" TEXT,
"variables" JSONB NOT NULL,
"isGlobal" BOOLEAN NOT NULL,
CONSTRAINT "UserEnvironment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "InvitedUsers" (
"adminUid" TEXT NOT NULL,
"adminEmail" TEXT NOT NULL,
"inviteeEmail" TEXT NOT NULL,
"invitedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "UserRequest" (
"id" TEXT NOT NULL,
"collectionID" TEXT NOT NULL,
"userUid" TEXT NOT NULL,
"title" TEXT NOT NULL,
"request" JSONB NOT NULL,
"type" "ReqType" NOT NULL,
"orderIndex" INTEGER NOT NULL,
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedOn" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserCollection" (
"id" TEXT NOT NULL,
"parentID" TEXT,
"userUid" TEXT NOT NULL,
"title" TEXT NOT NULL,
"orderIndex" INTEGER NOT NULL,
"type" "ReqType" NOT NULL,
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedOn" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserCollection_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TeamMember_teamID_userUid_key" ON "TeamMember"("teamID", "userUid");
-- CreateIndex
CREATE INDEX "TeamInvitation_teamID_idx" ON "TeamInvitation"("teamID");
-- CreateIndex
CREATE UNIQUE INDEX "TeamInvitation_teamID_inviteeEmail_key" ON "TeamInvitation"("teamID", "inviteeEmail");
-- CreateIndex
CREATE UNIQUE INDEX "Shortcode_id_creatorUid_key" ON "Shortcode"("id", "creatorUid");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_deviceIdentifier_token_key" ON "VerificationToken"("deviceIdentifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "UserSettings_userUid_key" ON "UserSettings"("userUid");
-- CreateIndex
CREATE UNIQUE INDEX "InvitedUsers_inviteeEmail_key" ON "InvitedUsers"("inviteeEmail");
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamInvitation" ADD CONSTRAINT "TeamInvitation_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamCollection" ADD CONSTRAINT "TeamCollection_parentID_fkey" FOREIGN KEY ("parentID") REFERENCES "TeamCollection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamCollection" ADD CONSTRAINT "TeamCollection_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamRequest" ADD CONSTRAINT "TeamRequest_collectionID_fkey" FOREIGN KEY ("collectionID") REFERENCES "TeamCollection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamRequest" ADD CONSTRAINT "TeamRequest_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamEnvironment" ADD CONSTRAINT "TeamEnvironment_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VerificationToken" ADD CONSTRAINT "VerificationToken_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserSettings" ADD CONSTRAINT "UserSettings_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserHistory" ADD CONSTRAINT "UserHistory_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserEnvironment" ADD CONSTRAINT "UserEnvironment_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InvitedUsers" ADD CONSTRAINT "InvitedUsers_adminUid_fkey" FOREIGN KEY ("adminUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRequest" ADD CONSTRAINT "UserRequest_collectionID_fkey" FOREIGN KEY ("collectionID") REFERENCES "UserCollection"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRequest" ADD CONSTRAINT "UserRequest_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserCollection" ADD CONSTRAINT "UserCollection_parentID_fkey" FOREIGN KEY ("parentID") REFERENCES "UserCollection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserCollection" ADD CONSTRAINT "UserCollection_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -1,220 +0,0 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-1.1.x", "debian-openssl-3.0.x"]
}
model Team {
id String @id @default(cuid())
name String
members TeamMember[]
TeamInvitation TeamInvitation[]
TeamCollection TeamCollection[]
TeamRequest TeamRequest[]
TeamEnvironment TeamEnvironment[]
}
model TeamMember {
id String @id @default(uuid()) // Membership ID
role TeamMemberRole
userUid String
teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
@@unique([teamID, userUid])
}
model TeamInvitation {
id String @id @default(cuid())
teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
creatorUid String
inviteeEmail String
inviteeRole TeamMemberRole
@@unique([teamID, inviteeEmail])
@@index([teamID])
}
model TeamCollection {
id String @id @default(cuid())
parentID String?
data Json?
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
children TeamCollection[] @relation("TeamCollectionChildParent")
requests TeamRequest[]
teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
title String
orderIndex Int
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}
model TeamRequest {
id String @id @default(cuid())
collectionID String
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
title String
request Json
orderIndex Int
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}
model Shortcode {
id String @id @unique
request Json
embedProperties Json?
creatorUid String?
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")
}
model TeamEnvironment {
id String @id @default(cuid())
teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
name String
variables Json
}
model User {
uid String @id @default(cuid())
displayName String?
email String? @unique
photoURL String?
isAdmin Boolean @default(false)
refreshToken String?
providerAccounts Account[]
VerificationToken VerificationToken[]
settings UserSettings?
UserHistory UserHistory[]
UserEnvironments UserEnvironment[]
userCollections UserCollection[]
userRequests UserRequest[]
currentRESTSession Json?
currentGQLSession Json?
createdOn DateTime @default(now()) @db.Timestamp(3)
invitedUsers InvitedUsers[]
shortcodes Shortcode[]
}
model Account {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [uid], onDelete: Cascade)
provider String
providerAccountId String
providerRefreshToken String?
providerAccessToken String?
providerScope String?
loggedIn DateTime @default(now()) @db.Timestamp(3)
@@unique(fields: [provider, providerAccountId], name: "verifyProviderAccount")
}
model VerificationToken {
deviceIdentifier String
token String @unique @default(cuid())
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
expiresOn DateTime @db.Timestamp(3)
@@unique(fields: [deviceIdentifier, token], name: "passwordless_deviceIdentifier_tokens")
}
model UserSettings {
id String @id @default(cuid())
userUid String @unique
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
properties Json
updatedOn DateTime @updatedAt @db.Timestamp(3)
}
model UserHistory {
id String @id @default(cuid())
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
reqType ReqType
request Json
responseMetadata Json
isStarred Boolean
executedOn DateTime @default(now()) @db.Timestamp(3)
}
enum ReqType {
REST
GQL
}
model UserEnvironment {
id String @id @default(cuid())
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
name String?
variables Json
isGlobal Boolean
}
model InvitedUsers {
adminUid String
user User @relation(fields: [adminUid], references: [uid], onDelete: Cascade)
adminEmail String
inviteeEmail String @unique
invitedOn DateTime @default(now()) @db.Timestamp(3)
}
model UserRequest {
id String @id @default(cuid())
userCollection UserCollection @relation(fields: [collectionID], references: [id])
collectionID String
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
title String
request Json
type ReqType
orderIndex Int
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}
model UserCollection {
id String @id @default(cuid())
parentID String?
parent UserCollection? @relation("ParentUserCollection", fields: [parentID], references: [id], onDelete: Cascade)
children UserCollection[] @relation("ParentUserCollection")
requests UserRequest[]
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
title String
data Json?
orderIndex Int
type ReqType
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}
enum TeamMemberRole {
OWNER
VIEWER
EDITOR
}
model InfraConfig {
id String @id @default(cuid())
name String @unique
value String?
active Boolean @default(true) // Use case: Let's say, Admin wants to disable Google SSO, but doesn't want to delete the config
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}

View File

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

View File

@@ -1,9 +0,0 @@
import { ObjectType, OmitType } from '@nestjs/graphql';
import { User } from 'src/user/user.model';
@ObjectType()
export class Admin extends OmitType(User, [
'isAdmin',
'currentRESTSession',
'currentGQLSession',
]) {}

View File

@@ -1,32 +0,0 @@
import { Module } from '@nestjs/common';
import { AdminResolver } from './admin.resolver';
import { AdminService } from './admin.service';
import { PrismaModule } from '../prisma/prisma.module';
import { PubSubModule } from '../pubsub/pubsub.module';
import { UserModule } from '../user/user.module';
import { TeamModule } from '../team/team.module';
import { TeamInvitationModule } from '../team-invitation/team-invitation.module';
import { TeamEnvironmentsModule } from '../team-environments/team-environments.module';
import { TeamCollectionModule } from '../team-collection/team-collection.module';
import { TeamRequestModule } from '../team-request/team-request.module';
import { InfraResolver } from './infra.resolver';
import { ShortcodeModule } from 'src/shortcode/shortcode.module';
import { InfraConfigModule } from 'src/infra-config/infra-config.module';
@Module({
imports: [
PrismaModule,
PubSubModule,
UserModule,
TeamModule,
TeamInvitationModule,
TeamEnvironmentsModule,
TeamCollectionModule,
TeamRequestModule,
ShortcodeModule,
InfraConfigModule,
],
providers: [InfraResolver, AdminResolver, AdminService],
exports: [AdminService],
})
export class AdminModule {}

View File

@@ -1,374 +0,0 @@
import {
Args,
ID,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
Subscription,
} from '@nestjs/graphql';
import { Admin } from './admin.model';
import { UseGuards } from '@nestjs/common';
import { GqlAuthGuard } from '../guards/gql-auth.guard';
import { GqlAdminGuard } from './guards/gql-admin.guard';
import { GqlAdmin } from './decorators/gql-admin.decorator';
import { AdminService } from './admin.service';
import * as E from 'fp-ts/Either';
import { throwErr } from '../utils';
import { AuthUser } from '../types/AuthUser';
import { InvitedUser } from './invited-user.model';
import { GqlUser } from '../decorators/gql-user.decorator';
import { PubSubService } from '../pubsub/pubsub.service';
import { Team, TeamMember } from '../team/team.model';
import {
AddUserToTeamArgs,
ChangeUserRoleInTeamArgs,
} from './input-types.args';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { SkipThrottle } from '@nestjs/throttler';
import { UserDeletionResult } from 'src/user/user.model';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => Admin)
export class AdminResolver {
constructor(
private adminService: AdminService,
private readonly pubsub: PubSubService,
) {}
/* Query */
@Query(() => Admin, {
description: 'Gives details of the admin executing this query',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
admin(@GqlAdmin() admin: Admin) {
return admin;
}
/* Mutations */
@Mutation(() => InvitedUser, {
description: 'Invite a user to the infra using email',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async inviteNewUser(
@GqlUser() adminUser: AuthUser,
@Args({
name: 'inviteeEmail',
description: 'invitee email',
})
inviteeEmail: string,
): Promise<InvitedUser> {
const invitedUser = await this.adminService.inviteUserToSignInViaEmail(
adminUser.uid,
adminUser.email,
inviteeEmail,
);
if (E.isLeft(invitedUser)) throwErr(invitedUser.left);
return invitedUser.right;
}
@Mutation(() => Boolean, {
description: 'Revoke a user invites by invitee emails',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async revokeUserInvitationsByAdmin(
@Args({
name: 'inviteeEmails',
description: 'Invitee Emails',
type: () => [String],
})
inviteeEmails: string[],
): Promise<boolean> {
const invite = await this.adminService.revokeUserInvitations(inviteeEmails);
if (E.isLeft(invite)) throwErr(invite.left);
return invite.right;
}
@Mutation(() => Boolean, {
description: 'Delete an user account from infra',
deprecationReason: 'Use removeUsersByAdmin instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async removeUserByAdmin(
@Args({
name: 'userUID',
description: 'users UID',
type: () => ID,
})
userUID: string,
): Promise<boolean> {
const removedUser = await this.adminService.removeUserAccount(userUID);
if (E.isLeft(removedUser)) throwErr(removedUser.left);
return removedUser.right;
}
@Mutation(() => [UserDeletionResult], {
description: 'Delete user accounts from infra',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async removeUsersByAdmin(
@Args({
name: 'userUIDs',
description: 'users UID',
type: () => [ID],
})
userUIDs: string[],
): Promise<UserDeletionResult[]> {
const deletionResults = await this.adminService.removeUserAccounts(
userUIDs,
);
if (E.isLeft(deletionResults)) throwErr(deletionResults.left);
return deletionResults.right;
}
@Mutation(() => Boolean, {
description: 'Make user an admin',
deprecationReason: 'Use makeUsersAdmin instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async makeUserAdmin(
@Args({
name: 'userUID',
description: 'users UID',
type: () => ID,
})
userUID: string,
): Promise<boolean> {
const admin = await this.adminService.makeUserAdmin(userUID);
if (E.isLeft(admin)) throwErr(admin.left);
return admin.right;
}
@Mutation(() => Boolean, {
description: 'Make users an admin',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async makeUsersAdmin(
@Args({
name: 'userUIDs',
description: 'users UID',
type: () => [ID],
})
userUIDs: string[],
): Promise<boolean> {
const isUpdated = await this.adminService.makeUsersAdmin(userUIDs);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return isUpdated.right;
}
@Mutation(() => Boolean, {
description: 'Update user display name',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async updateUserDisplayNameByAdmin(
@Args({
name: 'userUID',
description: 'users UID',
type: () => ID,
})
userUID: string,
@Args({
name: 'displayName',
description: 'users display name',
})
displayName: string,
): Promise<boolean> {
const isUpdated = await this.adminService.updateUserDisplayName(
userUID,
displayName,
);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return isUpdated.right;
}
@Mutation(() => Boolean, {
description: 'Remove user as admin',
deprecationReason: 'Use demoteUsersByAdmin instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async removeUserAsAdmin(
@Args({
name: 'userUID',
description: 'users UID',
type: () => ID,
})
userUID: string,
): Promise<boolean> {
const admin = await this.adminService.removeUserAsAdmin(userUID);
if (E.isLeft(admin)) throwErr(admin.left);
return admin.right;
}
@Mutation(() => Boolean, {
description: 'Remove users as admin',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async demoteUsersByAdmin(
@Args({
name: 'userUIDs',
description: 'users UID',
type: () => [ID],
})
userUIDs: string[],
): Promise<boolean> {
const isUpdated = await this.adminService.demoteUsersByAdmin(userUIDs);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return isUpdated.right;
}
@Mutation(() => Team, {
description:
'Create a new team by providing the user uid to nominate as Team owner',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async createTeamByAdmin(
@GqlAdmin() adminUser: Admin,
@Args({
name: 'userUid',
description: 'users uid to make team owner',
type: () => ID,
})
userUid: string,
@Args({ name: 'name', description: 'Displayed name of the team' })
name: string,
): Promise<Team> {
const createdTeam = await this.adminService.createATeam(userUid, name);
if (E.isLeft(createdTeam)) throwErr(createdTeam.left);
return createdTeam.right;
}
@Mutation(() => TeamMember, {
description: 'Change the role of a user in a team',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async changeUserRoleInTeamByAdmin(
@GqlAdmin() adminUser: Admin,
@Args() args: ChangeUserRoleInTeamArgs,
): Promise<TeamMember> {
const updatedRole = await this.adminService.changeRoleOfUserTeam(
args.userUID,
args.teamID,
args.newRole,
);
if (E.isLeft(updatedRole)) throwErr(updatedRole.left);
return updatedRole.right;
}
@Mutation(() => Boolean, {
description: 'Remove the user from a team',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async removeUserFromTeamByAdmin(
@GqlAdmin() adminUser: Admin,
@Args({
name: 'userUid',
description: 'users UID',
type: () => ID,
})
userUid: string,
@Args({
name: 'teamID',
description: 'team ID',
type: () => ID,
})
teamID: string,
): Promise<boolean> {
const removedUser = await this.adminService.removeUserFromTeam(
userUid,
teamID,
);
if (E.isLeft(removedUser)) throwErr(removedUser.left);
return removedUser.right;
}
@Mutation(() => TeamMember, {
description: 'Add a user to a team with email and team member role',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async addUserToTeamByAdmin(
@GqlAdmin() adminUser: Admin,
@Args() args: AddUserToTeamArgs,
): Promise<TeamMember> {
const addedUser = await this.adminService.addUserToTeam(
args.teamID,
args.userEmail,
args.role,
);
if (E.isLeft(addedUser)) throwErr(addedUser.left);
return addedUser.right;
}
@Mutation(() => Team, {
description: 'Change a team name',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async renameTeamByAdmin(
@GqlAdmin() adminUser: Admin,
@Args({ name: 'teamID', description: 'ID of the team', type: () => ID })
teamID: string,
@Args({ name: 'newName', description: 'The updated name of the team' })
newName: string,
): Promise<Team> {
const renamedTeam = await this.adminService.renameATeam(teamID, newName);
if (E.isLeft(renamedTeam)) throwErr(renamedTeam.left);
return renamedTeam.right;
}
@Mutation(() => Boolean, {
description: 'Delete a team',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async deleteTeamByAdmin(
@Args({ name: 'teamID', description: 'ID of the team', type: () => ID })
teamID: string,
): Promise<boolean> {
const deletedTeam = await this.adminService.deleteATeam(teamID);
if (E.isLeft(deletedTeam)) throwErr(deletedTeam.left);
return deletedTeam.right;
}
@Mutation(() => Boolean, {
description: 'Revoke a team Invite by Invite ID',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async revokeTeamInviteByAdmin(
@Args({
name: 'inviteID',
description: 'Team Invite ID',
type: () => ID,
})
inviteID: string,
): Promise<boolean> {
const invite = await this.adminService.revokeTeamInviteByID(inviteID);
if (E.isLeft(invite)) throwErr(invite.left);
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 */
@Subscription(() => InvitedUser, {
description: 'Listen for User Invitation',
resolve: (value) => value,
})
@SkipThrottle()
@UseGuards(GqlAuthGuard, GqlAdminGuard)
userInvited(@GqlUser() admin: AuthUser) {
return this.pubsub.asyncIterator(`admin/${admin.uid}/invited`);
}
}

View File

@@ -1,298 +0,0 @@
import { AdminService } from './admin.service';
import { PubSubService } from '../pubsub/pubsub.service';
import { mockDeep } from 'jest-mock-extended';
import { InvitedUsers, User as DbUser } from '@prisma/client';
import { UserService } from '../user/user.service';
import { TeamService } from '../team/team.service';
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
import { TeamRequestService } from '../team-request/team-request.service';
import { TeamInvitationService } from '../team-invitation/team-invitation.service';
import { TeamCollectionService } from '../team-collection/team-collection.service';
import { MailerService } from '../mailer/mailer.service';
import { PrismaService } from 'src/prisma/prisma.service';
import {
DUPLICATE_EMAIL,
INVALID_EMAIL,
ONLY_ONE_ADMIN_ACCOUNT,
USER_ALREADY_INVITED,
USER_INVITATION_DELETION_FAILED,
USER_NOT_FOUND,
} from '../errors';
import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import * as E from 'fp-ts/Either';
const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>();
const mockUserService = mockDeep<UserService>();
const mockTeamService = mockDeep<TeamService>();
const mockTeamEnvironmentsService = mockDeep<TeamEnvironmentsService>();
const mockTeamRequestService = mockDeep<TeamRequestService>();
const mockTeamInvitationService = mockDeep<TeamInvitationService>();
const mockTeamCollectionService = mockDeep<TeamCollectionService>();
const mockMailerService = mockDeep<MailerService>();
const mockShortcodeService = mockDeep<ShortcodeService>();
const mockConfigService = mockDeep<ConfigService>();
const adminService = new AdminService(
mockUserService,
mockTeamService,
mockTeamCollectionService,
mockTeamRequestService,
mockTeamEnvironmentsService,
mockTeamInvitationService,
mockPubSub as any,
mockPrisma as any,
mockMailerService,
mockShortcodeService,
mockConfigService,
);
const invitedUsers: InvitedUsers[] = [
{
adminUid: 'uid1',
adminEmail: 'admin1@example.com',
inviteeEmail: 'i@example.com',
invitedOn: new Date(),
},
{
adminUid: 'uid2',
adminEmail: 'admin2@example.com',
inviteeEmail: 'u@example.com',
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('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 () => {
const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 };
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]);
// @ts-ignore
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
const results = await adminService.fetchInvitedUsers(paginationArgs);
expect(results).toEqual(invitedUsers);
});
test('should resolve left and return an empty array if invited users not found', async () => {
const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 };
mockPrisma.invitedUsers.findMany.mockResolvedValue([]);
const results = await adminService.fetchInvitedUsers(paginationArgs);
expect(results).toEqual([]);
});
});
describe('inviteUserToSignInViaEmail', () => {
test('should resolve right and create a invited user', async () => {
mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(null);
mockPrisma.invitedUsers.create.mockResolvedValueOnce(invitedUsers[0]);
const result = await adminService.inviteUserToSignInViaEmail(
invitedUsers[0].adminUid,
invitedUsers[0].adminEmail,
invitedUsers[0].inviteeEmail,
);
expect(mockPrisma.invitedUsers.create).toHaveBeenCalledWith({
data: {
adminUid: invitedUsers[0].adminUid,
adminEmail: invitedUsers[0].adminEmail,
inviteeEmail: invitedUsers[0].inviteeEmail,
},
});
return expect(result).toEqualRight(invitedUsers[0]);
});
test('should resolve right, create a invited user and publish a subscription', async () => {
mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(null);
mockPrisma.invitedUsers.create.mockResolvedValueOnce(invitedUsers[0]);
await adminService.inviteUserToSignInViaEmail(
invitedUsers[0].adminUid,
invitedUsers[0].adminEmail,
invitedUsers[0].inviteeEmail,
);
return expect(mockPubSub.publish).toHaveBeenCalledWith(
`admin/${invitedUsers[0].adminUid}/invited`,
invitedUsers[0],
);
});
test('should resolve left and return an error when invalid invitee email is passed', async () => {
const result = await adminService.inviteUserToSignInViaEmail(
invitedUsers[0].adminUid,
invitedUsers[0].adminEmail,
'invalidemail',
);
return expect(result).toEqualLeft(INVALID_EMAIL);
});
test('should resolve left and return an error when user already invited', async () => {
mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(invitedUsers[0]);
const result = await adminService.inviteUserToSignInViaEmail(
invitedUsers[0].adminUid,
invitedUsers[0].adminEmail,
invitedUsers[0].inviteeEmail,
);
return expect(result).toEqualLeft(USER_ALREADY_INVITED);
});
test('should resolve left and return an error when invitee and admin email is same', async () => {
const result = await adminService.inviteUserToSignInViaEmail(
invitedUsers[0].adminUid,
invitedUsers[0].inviteeEmail,
invitedUsers[0].inviteeEmail,
);
return expect(result).toEqualLeft(DUPLICATE_EMAIL);
});
});
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', () => {
test('should return count of all users in the organization', async () => {
mockUserService.getUsersCount.mockResolvedValueOnce(10);
const result = await adminService.getUsersCount();
expect(result).toEqual(10);
});
});
describe('getTeamsCount', () => {
test('should return count of all teams in the organization', async () => {
mockTeamService.getTeamsCount.mockResolvedValueOnce(10);
const result = await adminService.getTeamsCount();
expect(result).toEqual(10);
});
});
describe('getTeamCollectionsCount', () => {
test('should return count of all Team Collections in the organization', async () => {
mockTeamCollectionService.getTeamCollectionsCount.mockResolvedValueOnce(
10,
);
const result = await adminService.getTeamCollectionsCount();
expect(result).toEqual(10);
});
});
describe('getTeamRequestsCount', () => {
test('should return count of all Team Collections in the organization', async () => {
mockTeamRequestService.getTeamRequestsCount.mockResolvedValueOnce(10);
const result = await adminService.getTeamRequestsCount();
expect(result).toEqual(10);
});
});
});

View File

@@ -1,643 +0,0 @@
import { Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { PubSubService } from '../pubsub/pubsub.service';
import { PrismaService } from '../prisma/prisma.service';
import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option';
import { validateEmail } from '../utils';
import {
ADMIN_CAN_NOT_BE_DELETED,
DUPLICATE_EMAIL,
EMAIL_FAILED,
INVALID_EMAIL,
ONLY_ONE_ADMIN_ACCOUNT,
TEAM_INVITE_ALREADY_MEMBER,
TEAM_INVITE_NO_INVITE_FOUND,
USERS_NOT_FOUND,
USER_ALREADY_INVITED,
USER_INVITATION_DELETION_FAILED,
USER_IS_ADMIN,
USER_NOT_FOUND,
} from '../errors';
import { MailerService } from '../mailer/mailer.service';
import { InvitedUser } from './invited-user.model';
import { TeamService } from '../team/team.service';
import { TeamCollectionService } from '../team-collection/team-collection.service';
import { TeamRequestService } from '../team-request/team-request.service';
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
import { TeamInvitationService } from '../team-invitation/team-invitation.service';
import { TeamMemberRole } from '../team/team.model';
import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import { UserDeletionResult } from 'src/user/user.model';
@Injectable()
export class AdminService {
constructor(
private readonly userService: UserService,
private readonly teamService: TeamService,
private readonly teamCollectionService: TeamCollectionService,
private readonly teamRequestService: TeamRequestService,
private readonly teamEnvironmentsService: TeamEnvironmentsService,
private readonly teamInvitationService: TeamInvitationService,
private readonly pubsub: PubSubService,
private readonly prisma: PrismaService,
private readonly mailerService: MailerService,
private readonly shortcodeService: ShortcodeService,
private readonly configService: ConfigService,
) {}
/**
* Fetch all the users in the infra.
* @param cursorID Users uid
* @param take number of users to fetch
* @returns an Either of array of user or error
* @deprecated use fetchUsersV2 instead
*/
async fetchUsers(cursorID: string, take: number) {
const allUsers = await this.userService.fetchAllUsers(cursorID, take);
return allUsers;
}
/**
* Fetch all the users in the infra.
* @param searchString search on users displayName or email
* @param paginationOption pagination options
* @returns an Either of array of user or error
*/
async fetchUsersV2(
searchString: string,
paginationOption: OffsetPaginationArgs,
) {
const allUsers = await this.userService.fetchAllUsersV2(
searchString,
paginationOption,
);
return allUsers;
}
/**
* Invite a user to join the infra.
* @param adminUID Admin's UID
* @param adminEmail Admin's email
* @param inviteeEmail Invitee's email
* @returns an Either of `InvitedUser` object or error
*/
async inviteUserToSignInViaEmail(
adminUID: string,
adminEmail: string,
inviteeEmail: string,
) {
if (inviteeEmail == adminEmail) return E.left(DUPLICATE_EMAIL);
if (!validateEmail(inviteeEmail)) return E.left(INVALID_EMAIL);
const alreadyInvitedUser = await this.prisma.invitedUsers.findFirst({
where: {
inviteeEmail: inviteeEmail,
},
});
if (alreadyInvitedUser != null) return E.left(USER_ALREADY_INVITED);
try {
await this.mailerService.sendUserInvitationEmail(inviteeEmail, {
template: 'user-invitation',
variables: {
inviteeEmail: inviteeEmail,
magicLink: `${this.configService.get('VITE_BASE_URL')}`,
},
});
} catch (e) {
return E.left(EMAIL_FAILED);
}
// Add invitee email to the list of invited users by admin
const dbInvitedUser = await this.prisma.invitedUsers.create({
data: {
adminUid: adminUID,
adminEmail: adminEmail,
inviteeEmail: inviteeEmail,
},
});
const invitedUser = <InvitedUser>{
adminEmail: dbInvitedUser.adminEmail,
adminUid: dbInvitedUser.adminUid,
inviteeEmail: dbInvitedUser.inviteeEmail,
invitedOn: dbInvitedUser.invitedOn,
};
// Publish invited user subscription
await this.pubsub.publish(`admin/${adminUID}/invited`, 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.
* @returns an Either of array of `InvitedUser` object or error
*/
async fetchInvitedUsers(paginationOption: OffsetPaginationArgs) {
const userEmailObjs = await this.prisma.user.findMany({
select: {
email: true,
},
});
const pendingInvitedUsers = await this.prisma.invitedUsers.findMany({
take: paginationOption.take,
skip: paginationOption.skip,
orderBy: {
invitedOn: 'desc',
},
where: {
NOT: {
inviteeEmail: {
in: userEmailObjs.map((user) => user.email),
},
},
},
});
const users: InvitedUser[] = pendingInvitedUsers.map(
(user) => <InvitedUser>{ ...user },
);
return users;
}
/**
* Fetch all the teams in the infra.
* @param cursorID team id
* @param take number of items to fetch
* @returns an array of teams
*/
async fetchAllTeams(cursorID: string, take: number) {
const allTeams = await this.teamService.fetchAllTeams(cursorID, take);
return allTeams;
}
/**
* Fetch the count of all the members in a team.
* @param teamID team id
* @returns a count of team members
*/
async membersCountInTeam(teamID: string) {
const teamMembersCount = await this.teamService.getCountOfMembersInTeam(
teamID,
);
return teamMembersCount;
}
/**
* Fetch count of all the collections in a team.
* @param teamID team id
* @returns a of count of collections
*/
async collectionCountInTeam(teamID: string) {
const teamCollectionsCount =
await this.teamCollectionService.totalCollectionsInTeam(teamID);
return teamCollectionsCount;
}
/**
* Fetch the count of all the requests in a team.
* @param teamID team id
* @returns a count of total requests in a team
*/
async requestCountInTeam(teamID: string) {
const teamRequestsCount =
await this.teamRequestService.totalRequestsInATeam(teamID);
return teamRequestsCount;
}
/**
* Fetch the count of all the environments in a team.
* @param teamID team id
* @returns a count of environments in a team
*/
async environmentCountInTeam(teamID: string) {
const envCount = await this.teamEnvironmentsService.totalEnvsInTeam(teamID);
return envCount;
}
/**
* Fetch all the invitations for a given team.
* @param teamID team id
* @returns an array team invitations
*/
async pendingInvitationCountInTeam(teamID: string) {
const invitations = await this.teamInvitationService.getTeamInvitations(
teamID,
);
return invitations;
}
/**
* Change the role of a user in a team
* @param userUid users uid
* @param teamID team id
* @returns an Either of updated `TeamMember` object or error
*/
async changeRoleOfUserTeam(
userUid: string,
teamID: string,
newRole: TeamMemberRole,
) {
const updatedTeamMember = await this.teamService.updateTeamMemberRole(
teamID,
userUid,
newRole,
);
if (E.isLeft(updatedTeamMember)) return E.left(updatedTeamMember.left);
return E.right(updatedTeamMember.right);
}
/**
* Remove the user from a team
* @param userUid users uid
* @param teamID team id
* @returns an Either of boolean or error
*/
async removeUserFromTeam(userUid: string, teamID: string) {
const removedUser = await this.teamService.leaveTeam(teamID, userUid);
if (E.isLeft(removedUser)) return E.left(removedUser.left);
return E.right(removedUser.right);
}
/**
* Add the user to a team
* @param teamID team id
* @param userEmail users email
* @param role team member role for the user
* @returns an Either of boolean or error
*/
async addUserToTeam(teamID: string, userEmail: string, role: TeamMemberRole) {
if (!validateEmail(userEmail)) return E.left(INVALID_EMAIL);
const user = await this.userService.findUserByEmail(userEmail);
if (O.isNone(user)) return E.left(USER_NOT_FOUND);
const teamMember = await this.teamService.getTeamMemberTE(
teamID,
user.value.uid,
)();
if (E.isLeft(teamMember)) {
const addedUser = await this.teamService.addMemberToTeamWithEmail(
teamID,
userEmail,
role,
);
if (E.isLeft(addedUser)) return E.left(addedUser.left);
const userInvitation =
await this.teamInvitationService.getTeamInviteByEmailAndTeamID(
userEmail,
teamID,
);
if (E.isRight(userInvitation)) {
await this.teamInvitationService.revokeInvitation(
userInvitation.right.id,
);
}
return E.right(addedUser.right);
}
return E.left(TEAM_INVITE_ALREADY_MEMBER);
}
/**
* Create a new team
* @param userUid user uid
* @param name team name
* @returns an Either of `Team` object or error
*/
async createATeam(userUid: string, name: string) {
const validUser = await this.userService.findUserById(userUid);
if (O.isNone(validUser)) return E.left(USER_NOT_FOUND);
const createdTeam = await this.teamService.createTeam(name, userUid);
if (E.isLeft(createdTeam)) return E.left(createdTeam.left);
return E.right(createdTeam.right);
}
/**
* Renames a team
* @param teamID team ID
* @param newName new team name
* @returns an Either of `Team` object or error
*/
async renameATeam(teamID: string, newName: string) {
const renamedTeam = await this.teamService.renameTeam(teamID, newName);
if (E.isLeft(renamedTeam)) return E.left(renamedTeam.left);
return E.right(renamedTeam.right);
}
/**
* Deletes a team
* @param teamID team ID
* @returns an Either of boolean or error
*/
async deleteATeam(teamID: string) {
const deleteTeam = await this.teamService.deleteTeam(teamID);
if (E.isLeft(deleteTeam)) return E.left(deleteTeam.left);
return E.right(deleteTeam.right);
}
/**
* Fetch all admin accounts
* @returns an array of admin users
*/
async fetchAdmins() {
const admins = this.userService.fetchAdminUsers();
return admins;
}
/**
* Fetch a user by UID
* @param userUid User UID
* @returns an Either of `User` obj or error
*/
async fetchUserInfo(userUid: string) {
const user = await this.userService.findUserById(userUid);
if (O.isNone(user)) return E.left(USER_NOT_FOUND);
return E.right(user.value);
}
/**
* Remove a user account by UID
* @param userUid User UID
* @returns an Either of boolean or error
* @deprecated use removeUserAccounts instead
*/
async removeUserAccount(userUid: string) {
const user = await this.userService.findUserById(userUid);
if (O.isNone(user)) return E.left(USER_NOT_FOUND);
if (user.value.isAdmin) return E.left(USER_IS_ADMIN);
const delUser = await this.userService.deleteUserByUID(user.value)();
if (E.isLeft(delUser)) return E.left(delUser.left);
return E.right(delUser.right);
}
/**
* Remove user (not Admin) accounts by UIDs
* @param userUIDs User UIDs
* @returns an Either of boolean or error
*/
async removeUserAccounts(userUIDs: string[]) {
const userDeleteResult: UserDeletionResult[] = [];
// step 1: fetch all users
const allUsersList = await this.userService.findUsersByIds(userUIDs);
if (allUsersList.length === 0) return E.left(USERS_NOT_FOUND);
// step 2: admin user can not be deleted without removing admin status/role
allUsersList.forEach((user) => {
if (user.isAdmin) {
userDeleteResult.push({
userUID: user.uid,
isDeleted: false,
errorMessage: ADMIN_CAN_NOT_BE_DELETED,
});
}
});
const nonAdminUsers = allUsersList.filter((user) => !user.isAdmin);
let deletedUserEmails: string[] = [];
// step 3: delete non-admin users
const deletionPromises = nonAdminUsers.map((user) => {
return this.userService
.deleteUserByUID(user)()
.then((res) => {
if (E.isLeft(res)) {
return {
userUID: user.uid,
isDeleted: false,
errorMessage: res.left,
} as UserDeletionResult;
}
deletedUserEmails.push(user.email);
return {
userUID: user.uid,
isDeleted: true,
errorMessage: null,
} as UserDeletionResult;
});
});
const promiseResult = await Promise.allSettled(deletionPromises);
// step 4: revoke all the invites sent to the deleted users
await this.revokeUserInvitations(deletedUserEmails);
// step 5: return the result
promiseResult.forEach((result) => {
if (result.status === 'fulfilled') {
userDeleteResult.push(result.value);
}
});
return E.right(userDeleteResult);
}
/**
* Make a user an admin
* @param userUid User UID
* @returns an Either of boolean or error
* @deprecated use makeUsersAdmin instead
*/
async makeUserAdmin(userUID: string) {
const admin = await this.userService.makeAdmin(userUID);
if (E.isLeft(admin)) return E.left(admin.left);
return E.right(true);
}
/**
* Make users to admin
* @param userUid User UIDs
* @returns an Either of boolean or error
*/
async makeUsersAdmin(userUIDs: string[]) {
const isUpdated = await this.userService.makeAdmins(userUIDs);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(true);
}
/**
* Remove user as admin
* @param userUid User UID
* @returns an Either of boolean or error
* @deprecated use demoteUsersByAdmin instead
*/
async removeUserAsAdmin(userUID: string) {
const adminUsers = await this.userService.fetchAdminUsers();
if (adminUsers.length === 1) return E.left(ONLY_ONE_ADMIN_ACCOUNT);
const admin = await this.userService.removeUserAsAdmin(userUID);
if (E.isLeft(admin)) return E.left(admin.left);
return E.right(true);
}
/**
* Remove users as admin
* @param userUIDs User UIDs
* @returns an Either of boolean or error
*/
async demoteUsersByAdmin(userUIDs: string[]) {
const adminUsers = await this.userService.fetchAdminUsers();
const remainingAdmins = adminUsers.filter(
(adminUser) => !userUIDs.includes(adminUser.uid),
);
if (remainingAdmins.length < 1) {
return E.left(ONLY_ONE_ADMIN_ACCOUNT);
}
const isUpdated = await this.userService.removeUsersAsAdmin(userUIDs);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(isUpdated.right);
}
/**
* Fetch list of all the Users in org
* @returns number of users in the org
*/
async getUsersCount() {
const usersCount = this.userService.getUsersCount();
return usersCount;
}
/**
* Fetch list of all the Teams in org
* @returns number of users in the org
*/
async getTeamsCount() {
const teamsCount = this.teamService.getTeamsCount();
return teamsCount;
}
/**
* Fetch list of all the Team Collections in org
* @returns number of users in the org
*/
async getTeamCollectionsCount() {
const teamCollectionCount =
this.teamCollectionService.getTeamCollectionsCount();
return teamCollectionCount;
}
/**
* Fetch list of all the Team Requests in org
* @returns number of users in the org
*/
async getTeamRequestsCount() {
const teamRequestCount = this.teamRequestService.getTeamRequestsCount();
return teamRequestCount;
}
/**
* Get team info by ID
* @param teamID Team ID
* @returns an Either of `Team` or error
*/
async getTeamInfo(teamID: string) {
const team = await this.teamService.getTeamWithIDTE(teamID)();
if (E.isLeft(team)) return E.left(team.left);
return E.right(team.right);
}
/**
* Revoke a team invite by ID
* @param inviteID Team Invite ID
* @returns an Either of boolean or error
*/
async revokeTeamInviteByID(inviteID: string) {
const teamInvite = await this.teamInvitationService.revokeInvitation(
inviteID,
);
if (E.isLeft(teamInvite)) return E.left(teamInvite.left);
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);
}
}

View File

@@ -1,9 +0,0 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
export const GqlAdmin = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req.user;
},
);

View File

@@ -1,14 +0,0 @@
import { Injectable, ExecutionContext, CanActivate } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
@Injectable()
export class GqlAdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const ctx = GqlExecutionContext.create(context);
const { req, headers } = ctx.getContext();
const request = headers ? headers : req;
const user = request.user;
if (user.isAdmin) return true;
else return false;
}
}

View File

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

View File

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

View File

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

View File

@@ -1,43 +0,0 @@
import { Field, ID, ArgsType } from '@nestjs/graphql';
import { TeamMemberRole } from '../team/team.model';
@ArgsType()
export class ChangeUserRoleInTeamArgs {
@Field(() => ID, {
name: 'userUID',
description: 'users UID',
})
userUID: string;
@Field(() => ID, {
name: 'teamID',
description: 'team ID',
})
teamID: string;
@Field(() => TeamMemberRole, {
name: 'newRole',
description: 'updated team role',
})
newRole: TeamMemberRole;
}
@ArgsType()
export class AddUserToTeamArgs {
@Field(() => ID, {
name: 'teamID',
description: 'team ID',
})
teamID: string;
@Field(() => TeamMemberRole, {
name: 'role',
description: 'The role of the user to add in the team',
})
role: TeamMemberRole;
@Field({
name: 'userEmail',
description: 'Email of the user to add to team',
})
userEmail: string;
}

View File

@@ -1,24 +0,0 @@
import { ObjectType, ID, Field } from '@nestjs/graphql';
@ObjectType()
export class InvitedUser {
@Field(() => ID, {
description: 'Admin UID',
})
adminUid: string;
@Field({
description: 'Admin email',
})
adminEmail: string;
@Field({
description: 'Invitee email',
})
inviteeEmail: string;
@Field({
description: 'Date when the user invitation was sent',
})
invitedOn: Date;
}

View File

@@ -1,9 +0,0 @@
import { Controller, Get } from '@nestjs/common';
@Controller('ping')
export class AppController {
@Get()
ping(): string {
return 'Success';
}
}

View File

@@ -1,107 +0,0 @@
import { ForbiddenException, HttpException, Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { UserModule } from './user/user.module';
import { GQLComplexityPlugin } from './plugins/GQLComplexityPlugin';
import { AuthModule } from './auth/auth.module';
import { UserSettingsModule } from './user-settings/user-settings.module';
import { UserEnvironmentsModule } from './user-environment/user-environments.module';
import { UserRequestModule } from './user-request/user-request.module';
import { UserHistoryModule } from './user-history/user-history.module';
import { subscriptionContextCookieParser } from './auth/helper';
import { TeamModule } from './team/team.module';
import { TeamEnvironmentsModule } from './team-environments/team-environments.module';
import { TeamCollectionModule } from './team-collection/team-collection.module';
import { TeamRequestModule } from './team-request/team-request.module';
import { TeamInvitationModule } from './team-invitation/team-invitation.module';
import { AdminModule } from './admin/admin.module';
import { UserCollectionModule } from './user-collection/user-collection.module';
import { ShortcodeModule } from './shortcode/shortcode.module';
import { COOKIES_NOT_FOUND } from './errors';
import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { InfraConfigModule } from './infra-config/infra-config.module';
import { loadInfraConfiguration } from './infra-config/helper';
import { MailerModule } from './mailer/mailer.module';
import { PosthogModule } from './posthog/posthog.module';
import { ScheduleModule } from '@nestjs/schedule';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [async () => loadInfraConfiguration()],
}),
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
return {
buildSchemaOptions: {
numberScalarMode: 'integer',
},
playground: configService.get('PRODUCTION') !== 'true',
autoSchemaFile: true,
installSubscriptionHandlers: true,
subscriptions: {
'subscriptions-transport-ws': {
path: '/graphql',
onConnect: (_, websocket) => {
try {
const cookies = subscriptionContextCookieParser(
websocket.upgradeReq.headers.cookie,
);
return {
headers: { ...websocket?.upgradeReq?.headers, cookies },
};
} catch (error) {
throw new HttpException(COOKIES_NOT_FOUND, 400, {
cause: new Error(COOKIES_NOT_FOUND),
});
}
},
},
},
context: ({ req, res, connection }) => ({
req,
res,
connection,
}),
};
},
}),
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => [
{
ttl: +configService.get('RATE_LIMIT_TTL'),
limit: +configService.get('RATE_LIMIT_MAX'),
},
],
}),
MailerModule.register(),
UserModule,
AuthModule.register(),
AdminModule,
UserSettingsModule,
UserEnvironmentsModule,
UserHistoryModule,
UserRequestModule,
TeamModule,
TeamEnvironmentsModule,
TeamCollectionModule,
TeamRequestModule,
TeamInvitationModule,
UserCollectionModule,
ShortcodeModule,
InfraConfigModule,
PosthogModule,
ScheduleModule.forRoot(),
],
providers: [GQLComplexityPlugin],
controllers: [AppController],
})
export class AppModule {}

View File

@@ -1,191 +0,0 @@
import {
Body,
Controller,
Get,
Post,
Query,
Request,
Res,
UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { SignInMagicDto } from './dto/signin-magic.dto';
import { VerifyMagicDto } from './dto/verify-magic.dto';
import { Response } from 'express';
import * as E from 'fp-ts/Either';
import { RTJwtAuthGuard } from './guards/rt-jwt-auth.guard';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { GqlUser } from 'src/decorators/gql-user.decorator';
import { AuthUser } from 'src/types/AuthUser';
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
import { AuthProvider, authCookieHandler, authProviderCheck } from './helper';
import { GoogleSSOGuard } from './guards/google-sso.guard';
import { GithubSSOGuard } from './guards/github-sso.guard';
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import { SkipThrottle } from '@nestjs/throttler';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'auth', version: '1' })
export class AuthController {
constructor(
private authService: AuthService,
private configService: ConfigService,
) {}
@Get('providers')
async getAuthProviders() {
const providers = await this.authService.getAuthProviders();
return { providers };
}
/**
** Route to initiate magic-link auth for a users email
*/
@Post('signin')
async signInMagicLink(
@Body() authData: SignInMagicDto,
@Query('origin') origin: string,
) {
if (
!authProviderCheck(
AuthProvider.EMAIL,
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
)
) {
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
}
const deviceIdToken = await this.authService.signInMagicLink(
authData.email,
origin,
);
if (E.isLeft(deviceIdToken)) throwHTTPErr(deviceIdToken.left);
return deviceIdToken.right;
}
/**
** Route to verify and sign in a valid user via magic-link
*/
@Post('verify')
async verify(@Body() data: VerifyMagicDto, @Res() res: Response) {
const authTokens = await this.authService.verifyMagicLinkTokens(data);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
authCookieHandler(res, authTokens.right, false, null);
}
/**
** Route to refresh auth tokens with Refresh Token Rotation
* @see https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation
*/
@Get('refresh')
@UseGuards(RTJwtAuthGuard)
async refresh(
@GqlUser() user: AuthUser,
@RTCookie() refresh_token: string,
@Res() res,
) {
const newTokenPair = await this.authService.refreshAuthTokens(
refresh_token,
user,
);
if (E.isLeft(newTokenPair)) throwHTTPErr(newTokenPair.left);
authCookieHandler(res, newTokenPair.right, false, null);
}
/**
** Route to initiate SSO auth via Google
*/
@Get('google')
@UseGuards(GoogleSSOGuard)
async googleAuth(@Request() req) {}
/**
** Callback URL for Google SSO
* @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works
*/
@Get('google/callback')
@SkipThrottle()
@UseGuards(GoogleSSOGuard)
async googleAuthRedirect(@Request() req, @Res() res) {
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
authCookieHandler(
res,
authTokens.right,
true,
req.authInfo.state.redirect_uri,
);
}
/**
** Route to initiate SSO auth via Github
*/
@Get('github')
@UseGuards(GithubSSOGuard)
async githubAuth(@Request() req) {}
/**
** Callback URL for Github SSO
* @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works
*/
@Get('github/callback')
@SkipThrottle()
@UseGuards(GithubSSOGuard)
async githubAuthRedirect(@Request() req, @Res() res) {
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
authCookieHandler(
res,
authTokens.right,
true,
req.authInfo.state.redirect_uri,
);
}
/**
** Route to initiate SSO auth via Microsoft
*/
@Get('microsoft')
@UseGuards(MicrosoftSSOGuard)
async microsoftAuth(@Request() req) {}
/**
** Callback URL for Microsoft SSO
* @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works
*/
@Get('microsoft/callback')
@SkipThrottle()
@UseGuards(MicrosoftSSOGuard)
async microsoftAuthRedirect(@Request() req, @Res() res) {
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
authCookieHandler(
res,
authTokens.right,
true,
req.authInfo.state.redirect_uri,
);
}
/**
** Log user out by clearing cookies containing auth tokens
*/
@Get('logout')
async logout(@Res() res: Response) {
res.clearCookie('access_token');
res.clearCookie('refresh_token');
return res.status(200).send();
}
@Get('verify/admin')
@UseGuards(JwtAuthGuard)
async verifyAdmin(@GqlUser() user: AuthUser) {
const userInfo = await this.authService.verifyAdmin(user);
if (E.isLeft(userInfo)) throwHTTPErr(userInfo.left);
return userInfo.right;
}
}

View File

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

View File

@@ -1,425 +0,0 @@
import { HttpStatus } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Account, VerificationToken } from '@prisma/client';
import { mockDeep, mockFn } from 'jest-mock-extended';
import {
INVALID_EMAIL,
INVALID_MAGIC_LINK_DATA,
INVALID_REFRESH_TOKEN,
MAGIC_LINK_EXPIRED,
VERIFICATION_TOKEN_DATA_NOT_FOUND,
USER_NOT_FOUND,
USERS_NOT_FOUND,
} from 'src/errors';
import { MailerService } from 'src/mailer/mailer.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { AuthUser } from 'src/types/AuthUser';
import { UserService } from 'src/user/user.service';
import { AuthService } from './auth.service';
import * as O from 'fp-ts/Option';
import { VerifyMagicDto } from './dto/verify-magic.dto';
import { DateTime } from 'luxon';
import * as argon2 from 'argon2';
import * as E from 'fp-ts/Either';
import { ConfigService } from '@nestjs/config';
import { InfraConfigService } from 'src/infra-config/infra-config.service';
const mockPrisma = mockDeep<PrismaService>();
const mockUser = mockDeep<UserService>();
const mockJWT = mockDeep<JwtService>();
const mockMailer = mockDeep<MailerService>();
const mockConfigService = mockDeep<ConfigService>();
const mockInfraConfigService = mockDeep<InfraConfigService>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const authService = new AuthService(
mockUser,
mockPrisma,
mockJWT,
mockMailer,
mockConfigService,
mockInfraConfigService,
);
const currentTime = new Date();
const user: AuthUser = {
uid: '123344',
email: 'dwight@dundermifflin.com',
displayName: 'Dwight Schrute',
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false,
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
createdOn: currentTime,
currentGQLSession: {},
currentRESTSession: {},
};
const passwordlessData: VerificationToken = {
deviceIdentifier: 'k23hb7u7gdcujhb',
token: 'jhhj24sdjvl',
userUid: user.uid,
expiresOn: new Date(),
};
const magicLinkVerify: VerifyMagicDto = {
deviceIdentifier: 'Dscdc',
token: 'SDcsdc',
};
const accountDetails: Account = {
id: '123dcdc',
userId: user.uid,
provider: 'email',
providerAccountId: user.uid,
providerRefreshToken: 'dscsdc',
providerAccessToken: 'sdcsdcsdc',
providerScope: 'user.email',
loggedIn: currentTime,
};
let nowPlus30 = new Date();
nowPlus30.setMinutes(nowPlus30.getMinutes() + 30000);
nowPlus30 = new Date(nowPlus30);
const encodedRefreshToken =
'$argon2id$v=19$m=65536,t=3,p=4$JTP8yZ8YXMHdafb5pB9Rfg$tdZrILUxMb9dQbu0uuyeReLgKxsgYnyUNbc5ZxQmy5I';
describe('signInMagicLink', () => {
test('Should throw error if email is not in valid format', async () => {
const result = await authService.signInMagicLink('bbbgmail.com', 'admin');
expect(result).toEqualLeft({
message: INVALID_EMAIL,
statusCode: HttpStatus.BAD_REQUEST,
});
});
test('Should successfully create a new user account and return the passwordless details', async () => {
// check to see if user exists, return none
mockUser.findUserByEmail.mockResolvedValue(O.none);
// create new user
mockUser.createUserViaMagicLink.mockResolvedValue(user);
// create new entry in VerificationToken table
mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData);
// Read env variable 'MAGIC_LINK_TOKEN_VALIDITY' from config service
mockConfigService.get.mockReturnValue('3');
const result = await authService.signInMagicLink(
'dwight@dundermifflin.com',
'admin',
);
expect(result).toEqualRight({
deviceIdentifier: passwordlessData.deviceIdentifier,
});
});
test('Should successfully return the passwordless details for a pre-existing user account', async () => {
// check to see if user exists, return error
mockUser.findUserByEmail.mockResolvedValueOnce(O.some(user));
// create new entry in VerificationToken table
mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData);
const result = await authService.signInMagicLink(
'dwight@dundermifflin.com',
'admin',
);
expect(result).toEqualRight({
deviceIdentifier: passwordlessData.deviceIdentifier,
});
});
});
describe('verifyMagicLinkTokens', () => {
test('Should throw INVALID_MAGIC_LINK_DATA if data is invalid', async () => {
mockPrisma.verificationToken.findUniqueOrThrow.mockRejectedValueOnce(
'NotFoundError',
);
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualLeft({
message: INVALID_MAGIC_LINK_DATA,
statusCode: HttpStatus.NOT_FOUND,
});
});
test('Should throw USER_NOT_FOUND if user is invalid', async () => {
// validatePasswordlessTokens
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce(
passwordlessData,
);
// findUserById
mockUser.findUserById.mockResolvedValue(O.none);
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualLeft({
message: USER_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,
});
});
test('Should successfully return auth token pair with provider account existing', async () => {
// validatePasswordlessTokens
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({
...passwordlessData,
expiresOn: nowPlus30,
});
// findUserById
mockUser.findUserById.mockResolvedValue(O.some(user));
// checkIfProviderAccountExists
mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails);
// mockPrisma.account.findUnique.mockResolvedValueOnce(null);
// generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
// deletePasswordlessVerificationToken
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualRight({
access_token: user.refreshToken,
refresh_token: user.refreshToken,
});
});
test('Should successfully return auth token pair with provider account not existing', async () => {
// validatePasswordlessTokens
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({
...passwordlessData,
expiresOn: nowPlus30,
});
// findUserById
mockUser.findUserById.mockResolvedValue(O.some(user));
// checkIfProviderAccountExists
mockPrisma.account.findUnique.mockResolvedValueOnce(null);
mockUser.createUserSSO.mockResolvedValueOnce(user);
// generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
// deletePasswordlessVerificationToken
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualRight({
access_token: user.refreshToken,
refresh_token: user.refreshToken,
});
});
test('Should throw MAGIC_LINK_EXPIRED if passwordless token is expired', async () => {
// validatePasswordlessTokens
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce(
passwordlessData,
);
// findUserById
mockUser.findUserById.mockResolvedValue(O.some(user));
// checkIfProviderAccountExists
mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails);
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualLeft({
message: MAGIC_LINK_EXPIRED,
statusCode: HttpStatus.UNAUTHORIZED,
});
});
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
// validatePasswordlessTokens
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({
...passwordlessData,
expiresOn: nowPlus30,
});
// findUserById
mockUser.findUserById.mockResolvedValue(O.some(user));
// checkIfProviderAccountExists
mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails);
// mockPrisma.account.findUnique.mockResolvedValueOnce(null);
// generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
E.left(USER_NOT_FOUND),
);
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualLeft({
message: USER_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,
});
});
test('Should throw PASSWORDLESS_DATA_NOT_FOUND when deleting passwordlessVerification entry from DB', async () => {
// validatePasswordlessTokens
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({
...passwordlessData,
expiresOn: nowPlus30,
});
// findUserById
mockUser.findUserById.mockResolvedValue(O.some(user));
// checkIfProviderAccountExists
mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails);
// mockPrisma.account.findUnique.mockResolvedValueOnce(null);
// generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
// deletePasswordlessVerificationToken
mockPrisma.verificationToken.delete.mockRejectedValueOnce('RecordNotFound');
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualLeft({
message: VERIFICATION_TOKEN_DATA_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,
});
});
});
describe('generateAuthTokens', () => {
test('Should successfully generate tokens with valid inputs', async () => {
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
const result = await authService.generateAuthTokens(user.uid);
expect(result).toEqualRight({
access_token: 'hbfvdkhjbvkdvdfjvbnkhjb',
refresh_token: 'hbfvdkhjbvkdvdfjvbnkhjb',
});
});
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
E.left(USER_NOT_FOUND),
);
const result = await authService.generateAuthTokens(user.uid);
expect(result).toEqualLeft({
message: USER_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,
});
});
});
jest.mock('argon2', () => {
return {
verify: jest.fn((x, y) => {
if (y === null) return false;
return true;
}),
hash: jest.fn(),
};
});
describe('refreshAuthTokens', () => {
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
// generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
E.left(USER_NOT_FOUND),
);
const result = await authService.refreshAuthTokens(
'$argon2id$v=19$m=65536,t=3,p=4$MvVOam2clCOLtJFGEE26ZA$czvA5ez9hz+A/LML8QRgqgaFuWa5JcbwkH6r+imTQbs',
user,
);
expect(result).toEqualLeft({
message: USER_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,
});
});
test('Should throw USER_NOT_FOUND when user is invalid', async () => {
const result = await authService.refreshAuthTokens(
'jshdcbjsdhcbshdbc',
null,
);
expect(result).toEqualLeft({
message: USER_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,
});
});
test('Should successfully refresh the tokens and generate a new auth token pair', async () => {
// generateAuthTokens
mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
E.right({
...user,
refreshToken: 'sdhjcbjsdhcbshjdcb',
}),
);
const result = await authService.refreshAuthTokens(
'$argon2id$v=19$m=65536,t=3,p=4$MvVOam2clCOLtJFGEE26ZA$czvA5ez9hz+A/LML8QRgqgaFuWa5JcbwkH6r+imTQbs',
user,
);
expect(result).toEqualRight({
access_token: 'sdhjcbjsdhcbshjdcb',
refresh_token: 'sdhjcbjsdhcbshjdcb',
});
});
test('Should throw INVALID_REFRESH_TOKEN when the refresh token is invalid', async () => {
// generateAuthTokens
mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
mockPrisma.user.update.mockResolvedValueOnce({
...user,
refreshToken: 'sdhjcbjsdhcbshjdcb',
});
const result = await authService.refreshAuthTokens(null, user);
expect(result).toEqualLeft({
message: INVALID_REFRESH_TOKEN,
statusCode: HttpStatus.NOT_FOUND,
});
});
});
describe('verifyAdmin', () => {
test('should successfully elevate user to admin when userCount is 1 ', async () => {
// getUsersCount
mockUser.getUsersCount.mockResolvedValueOnce(1);
// makeAdmin
mockUser.makeAdmin.mockResolvedValueOnce(
E.right({
...user,
isAdmin: true,
}),
);
const result = await authService.verifyAdmin(user);
expect(result).toEqualRight({ isAdmin: true });
});
test('should return true if user is already an admin', async () => {
const result = await authService.verifyAdmin({ ...user, isAdmin: true });
expect(result).toEqualRight({ isAdmin: true });
});
test('should throw USERS_NOT_FOUND when userUid is invalid', async () => {
// getUsersCount
mockUser.getUsersCount.mockResolvedValueOnce(1);
// makeAdmin
mockUser.makeAdmin.mockResolvedValueOnce(E.left(USER_NOT_FOUND));
const result = await authService.verifyAdmin(user);
expect(result).toEqualLeft({
message: USER_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,
});
});
test('should return false when user is not an admin and userCount is greater than 1', async () => {
// getUsersCount
mockUser.getUsersCount.mockResolvedValueOnce(13);
const result = await authService.verifyAdmin(user);
expect(result).toEqualRight({ isAdmin: false });
});
});

View File

@@ -1,390 +0,0 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { MailerService } from 'src/mailer/mailer.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { UserService } from 'src/user/user.service';
import { VerifyMagicDto } from './dto/verify-magic.dto';
import { DateTime } from 'luxon';
import * as argon2 from 'argon2';
import * as bcrypt from 'bcrypt';
import * as O from 'fp-ts/Option';
import * as E from 'fp-ts/Either';
import { DeviceIdentifierToken } from 'src/types/Passwordless';
import {
INVALID_EMAIL,
INVALID_MAGIC_LINK_DATA,
VERIFICATION_TOKEN_DATA_NOT_FOUND,
MAGIC_LINK_EXPIRED,
USER_NOT_FOUND,
INVALID_REFRESH_TOKEN,
} from 'src/errors';
import { validateEmail } from 'src/utils';
import {
AccessTokenPayload,
AuthTokens,
RefreshTokenPayload,
} from 'src/types/AuthTokens';
import { JwtService } from '@nestjs/jwt';
import { RESTError } from 'src/types/RESTError';
import { AuthUser, IsAdmin } from 'src/types/AuthUser';
import { VerificationToken } from '@prisma/client';
import { Origin } from './helper';
import { ConfigService } from '@nestjs/config';
import { InfraConfigService } from 'src/infra-config/infra-config.service';
@Injectable()
export class AuthService {
constructor(
private usersService: UserService,
private prismaService: PrismaService,
private jwtService: JwtService,
private readonly mailerService: MailerService,
private readonly configService: ConfigService,
private infraConfigService: InfraConfigService,
) {}
/**
* Generate Id and token for email Magic-Link auth
*
* @param user User Object
* @returns Created VerificationToken token
*/
private async generateMagicLinkTokens(user: AuthUser) {
const salt = await bcrypt.genSalt(
parseInt(this.configService.get('TOKEN_SALT_COMPLEXITY')),
);
const expiresOn = DateTime.now()
.plus({
hours: parseInt(this.configService.get('MAGIC_LINK_TOKEN_VALIDITY')),
})
.toISO()
.toString();
const idToken = await this.prismaService.verificationToken.create({
data: {
deviceIdentifier: salt,
userUid: user.uid,
expiresOn: expiresOn,
},
});
return idToken;
}
/**
* Check if VerificationToken exist or not
*
* @param magicLinkTokens Object containing deviceIdentifier and token
* @returns Option of VerificationToken token
*/
private async validatePasswordlessTokens(magicLinkTokens: VerifyMagicDto) {
try {
const tokens =
await this.prismaService.verificationToken.findUniqueOrThrow({
where: {
passwordless_deviceIdentifier_tokens: {
deviceIdentifier: magicLinkTokens.deviceIdentifier,
token: magicLinkTokens.token,
},
},
});
return O.some(tokens);
} catch (error) {
return O.none;
}
}
/**
* Generate new refresh token for user
*
* @param userUid User Id
* @returns Generated refreshToken
*/
private async generateRefreshToken(userUid: string) {
const refreshTokenPayload: RefreshTokenPayload = {
iss: this.configService.get('VITE_BASE_URL'),
sub: userUid,
aud: [this.configService.get('VITE_BASE_URL')],
};
const refreshToken = await this.jwtService.sign(refreshTokenPayload, {
expiresIn: this.configService.get('REFRESH_TOKEN_VALIDITY'), //7 Days
});
const refreshTokenHash = await argon2.hash(refreshToken);
const updatedUser = await this.usersService.UpdateUserRefreshToken(
refreshTokenHash,
userUid,
);
if (E.isLeft(updatedUser))
return E.left(<RESTError>{
message: updatedUser.left,
statusCode: HttpStatus.NOT_FOUND,
});
return E.right(refreshToken);
}
/**
* Generate access and refresh token pair
*
* @param userUid User ID
* @returns Either of generated AuthTokens
*/
async generateAuthTokens(userUid: string) {
const accessTokenPayload: AccessTokenPayload = {
iss: this.configService.get('VITE_BASE_URL'),
sub: userUid,
aud: [this.configService.get('VITE_BASE_URL')],
};
const refreshToken = await this.generateRefreshToken(userUid);
if (E.isLeft(refreshToken)) return E.left(refreshToken.left);
return E.right(<AuthTokens>{
access_token: await this.jwtService.sign(accessTokenPayload, {
expiresIn: this.configService.get('ACCESS_TOKEN_VALIDITY'), //1 Day
}),
refresh_token: refreshToken.right,
});
}
/**
* Deleted used VerificationToken tokens
*
* @param passwordlessTokens VerificationToken entry to delete from DB
* @returns Either of deleted VerificationToken token
*/
private async deleteMagicLinkVerificationTokens(
passwordlessTokens: VerificationToken,
) {
try {
const deletedPasswordlessToken =
await this.prismaService.verificationToken.delete({
where: {
passwordless_deviceIdentifier_tokens: {
deviceIdentifier: passwordlessTokens.deviceIdentifier,
token: passwordlessTokens.token,
},
},
});
return E.right(deletedPasswordlessToken);
} catch (error) {
return E.left(VERIFICATION_TOKEN_DATA_NOT_FOUND);
}
}
/**
* Verify if Provider account exists for User
*
* @param user User Object
* @param SSOUserData User data from SSO providers (Magic,Google,Github,Microsoft)
* @returns Either of existing user provider Account
*/
async checkIfProviderAccountExists(user: AuthUser, SSOUserData) {
const provider = await this.prismaService.account.findUnique({
where: {
verifyProviderAccount: {
provider: SSOUserData.provider,
providerAccountId: SSOUserData.id,
},
},
});
if (!provider) return O.none;
return O.some(provider);
}
/**
* Create User (if not already present) and send email to initiate Magic-Link auth
*
* @param email User's email
* @returns Either containing DeviceIdentifierToken
*/
async signInMagicLink(email: string, origin: string) {
if (!validateEmail(email))
return E.left({
message: INVALID_EMAIL,
statusCode: HttpStatus.BAD_REQUEST,
});
let user: AuthUser;
const queriedUser = await this.usersService.findUserByEmail(email);
if (O.isNone(queriedUser)) {
user = await this.usersService.createUserViaMagicLink(email);
} else {
user = queriedUser.value;
}
const generatedTokens = await this.generateMagicLinkTokens(user);
// check to see if origin is valid
let url: string;
switch (origin) {
case Origin.ADMIN:
url = this.configService.get('VITE_ADMIN_URL');
break;
case Origin.APP:
url = this.configService.get('VITE_BASE_URL');
break;
default:
// if origin is invalid by default set URL to Hoppscotch-App
url = this.configService.get('VITE_BASE_URL');
}
await this.mailerService.sendEmail(email, {
template: 'user-invitation',
variables: {
inviteeEmail: email,
magicLink: `${url}/enter?token=${generatedTokens.token}`,
},
});
return E.right(<DeviceIdentifierToken>{
deviceIdentifier: generatedTokens.deviceIdentifier,
});
}
/**
* Verify and authenticate user from received data for Magic-Link
*
* @param magicLinkIDTokens magic-link verification tokens from client
* @returns Either of generated AuthTokens
*/
async verifyMagicLinkTokens(
magicLinkIDTokens: VerifyMagicDto,
): Promise<E.Right<AuthTokens> | E.Left<RESTError>> {
const passwordlessTokens = await this.validatePasswordlessTokens(
magicLinkIDTokens,
);
if (O.isNone(passwordlessTokens))
return E.left({
message: INVALID_MAGIC_LINK_DATA,
statusCode: HttpStatus.NOT_FOUND,
});
const user = await this.usersService.findUserById(
passwordlessTokens.value.userUid,
);
if (O.isNone(user))
return E.left({
message: USER_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,
});
/**
* * Check to see if entry for Magic-Link is present in the Account table for user
* * If user was created with another provider findUserById may return true
*/
const profile = {
provider: 'magic',
id: user.value.email,
};
const providerAccountExists = await this.checkIfProviderAccountExists(
user.value,
profile,
);
if (O.isNone(providerAccountExists)) {
await this.usersService.createProviderAccount(
user.value,
null,
null,
profile,
);
}
const currentTime = DateTime.now().toISO();
if (currentTime > passwordlessTokens.value.expiresOn.toISOString())
return E.left({
message: MAGIC_LINK_EXPIRED,
statusCode: HttpStatus.UNAUTHORIZED,
});
const tokens = await this.generateAuthTokens(
passwordlessTokens.value.userUid,
);
if (E.isLeft(tokens))
return E.left({
message: tokens.left.message,
statusCode: tokens.left.statusCode,
});
const deletedPasswordlessToken =
await this.deleteMagicLinkVerificationTokens(passwordlessTokens.value);
if (E.isLeft(deletedPasswordlessToken))
return E.left({
message: deletedPasswordlessToken.left,
statusCode: HttpStatus.NOT_FOUND,
});
return E.right(tokens.right);
}
/**
* Refresh refresh and auth tokens
*
* @param hashedRefreshToken Hashed refresh token received from client
* @param user User Object
* @returns Either of generated AuthTokens
*/
async refreshAuthTokens(hashedRefreshToken: string, user: AuthUser) {
// Check to see user is valid
if (!user)
return E.left({
message: USER_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,
});
// Check to see if the hashed refresh_token received from the client is the same as the refresh_token saved in the DB
const isTokenMatched = await argon2.verify(
user.refreshToken,
hashedRefreshToken,
);
if (!isTokenMatched)
return E.left({
message: INVALID_REFRESH_TOKEN,
statusCode: HttpStatus.NOT_FOUND,
});
// if tokens match, generate new pair of auth tokens
const generatedAuthTokens = await this.generateAuthTokens(user.uid);
if (E.isLeft(generatedAuthTokens))
return E.left({
message: generatedAuthTokens.left.message,
statusCode: generatedAuthTokens.left.statusCode,
});
return E.right(generatedAuthTokens.right);
}
/**
* Verify is signed in User is an admin or not
*
* @param user User Object
* @returns Either of boolean if user is admin or not
*/
async verifyAdmin(user: AuthUser) {
if (user.isAdmin) return E.right(<IsAdmin>{ isAdmin: true });
const usersCount = await this.usersService.getUsersCount();
if (usersCount === 1) {
const elevatedUser = await this.usersService.makeAdmin(user.uid);
if (E.isLeft(elevatedUser))
return E.left(<RESTError>{
message: elevatedUser.left,
statusCode: HttpStatus.NOT_FOUND,
});
return E.right(<IsAdmin>{ isAdmin: true });
}
return E.right(<IsAdmin>{ isAdmin: false });
}
getAuthProviders() {
return this.infraConfigService.getAllowedAuthProviders();
}
}

View File

@@ -1,4 +0,0 @@
// Inputs to initiate Magic-Link auth flow
export class SignInMagicDto {
email: string;
}

View File

@@ -1,5 +0,0 @@
// Inputs to verify and sign a user in via magic-link
export class VerifyMagicDto {
deviceIdentifier: string;
token: string;
}

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

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

View File

@@ -1,5 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class RTJwtAuthGuard extends AuthGuard('jwt-refresh') {}

View File

@@ -1,127 +0,0 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { DateTime } from 'luxon';
import { AuthTokens } from 'src/types/AuthTokens';
import { Response } from 'express';
import * as cookie from 'cookie';
import { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors';
import { throwErr } from 'src/utils';
import { ConfigService } from '@nestjs/config';
enum AuthTokenType {
ACCESS_TOKEN = 'access_token',
REFRESH_TOKEN = 'refresh_token',
}
export enum Origin {
ADMIN = 'admin',
APP = 'app',
}
export enum AuthProvider {
GOOGLE = 'GOOGLE',
GITHUB = 'GITHUB',
MICROSOFT = 'MICROSOFT',
EMAIL = 'EMAIL',
}
/**
* Sets and returns the cookies in the response object on successful authentication
* @param res Express Response Object
* @param authTokens Object containing the access and refresh tokens
* @param redirect if true will redirect to provided URL else just send a 200 status code
*/
export const authCookieHandler = (
res: Response,
authTokens: AuthTokens,
redirect: boolean,
redirectUrl: string | null,
) => {
const configService = new ConfigService();
const currentTime = DateTime.now();
const accessTokenValidity = currentTime
.plus({
milliseconds: parseInt(configService.get('ACCESS_TOKEN_VALIDITY')),
})
.toMillis();
const refreshTokenValidity = currentTime
.plus({
milliseconds: parseInt(configService.get('REFRESH_TOKEN_VALIDITY')),
})
.toMillis();
res.cookie(AuthTokenType.ACCESS_TOKEN, authTokens.access_token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: accessTokenValidity,
});
res.cookie(AuthTokenType.REFRESH_TOKEN, authTokens.refresh_token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: refreshTokenValidity,
});
if (!redirect) {
return res.status(HttpStatus.OK).send();
}
// check to see if redirectUrl is a whitelisted url
const whitelistedOrigins = configService
.get('WHITELISTED_ORIGINS')
.split(',');
if (!whitelistedOrigins.includes(redirectUrl))
// if it is not redirect by default to REDIRECT_URL
redirectUrl = configService.get('REDIRECT_URL');
return res.status(HttpStatus.OK).redirect(redirectUrl);
};
/**
* Decode the cookie header from incoming websocket connects and returns a auth token pair
* @param rawCookies cookies from the websocket connection
* @returns AuthTokens for JWT strategy to use
*/
export const subscriptionContextCookieParser = (rawCookies: string) => {
const cookies = cookie.parse(rawCookies);
if (
!cookies[AuthTokenType.ACCESS_TOKEN] &&
!cookies[AuthTokenType.REFRESH_TOKEN]
) {
throw new HttpException(COOKIES_NOT_FOUND, 400, {
cause: new Error(COOKIES_NOT_FOUND),
});
}
return <AuthTokens>{
access_token: cookies[AuthTokenType.ACCESS_TOKEN],
refresh_token: cookies[AuthTokenType.REFRESH_TOKEN],
};
};
/**
* Check to see if given auth provider is present in the VITE_ALLOWED_AUTH_PROVIDERS env variable
*
* @param provider Provider we want to check the presence of
* @returns Boolean if provider specified is present or not
*/
export function authProviderCheck(
provider: string,
VITE_ALLOWED_AUTH_PROVIDERS: string,
) {
if (!provider) {
throwErr(AUTH_PROVIDER_NOT_SPECIFIED);
}
const envVariables = VITE_ALLOWED_AUTH_PROVIDERS
? VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
provider.trim().toUpperCase(),
)
: [];
if (!envVariables.includes(provider.toUpperCase())) return false;
return true;
}

View File

@@ -1,70 +0,0 @@
import { Strategy } from 'passport-github2';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../auth.service';
import { UserService } from 'src/user/user.service';
import * as O from 'fp-ts/Option';
import * as E from 'fp-ts/Either';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class GithubStrategy extends PassportStrategy(Strategy) {
constructor(
private authService: AuthService,
private usersService: UserService,
private configService: ConfigService,
) {
super({
clientID: configService.get('INFRA.GITHUB_CLIENT_ID'),
clientSecret: configService.get('INFRA.GITHUB_CLIENT_SECRET'),
callbackURL: configService.get('INFRA.GITHUB_CALLBACK_URL'),
scope: [configService.get('INFRA.GITHUB_SCOPE')],
store: true,
});
}
async validate(accessToken, refreshToken, profile, done) {
const user = await this.usersService.findUserByEmail(
profile.emails[0].value,
);
if (O.isNone(user)) {
const createdUser = await this.usersService.createUserSSO(
accessToken,
refreshToken,
profile,
);
return createdUser;
}
/**
* * displayName and photoURL maybe null if user logged-in via magic-link before SSO
*/
if (!user.value.displayName || !user.value.photoURL) {
const updatedUser = await this.usersService.updateUserDetails(
user.value,
profile,
);
if (E.isLeft(updatedUser)) {
throw new UnauthorizedException(updatedUser.left);
}
}
/**
* * Check to see if entry for Github is present in the Account table for user
* * If user was created with another provider findUserByEmail may return true
*/
const providerAccountExists =
await this.authService.checkIfProviderAccountExists(user.value, profile);
if (O.isNone(providerAccountExists))
await this.usersService.createProviderAccount(
user.value,
accessToken,
refreshToken,
profile,
);
return user.value;
}
}

View File

@@ -1,77 +0,0 @@
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UserService } from 'src/user/user.service';
import * as O from 'fp-ts/Option';
import { AuthService } from '../auth.service';
import * as E from 'fp-ts/Either';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy) {
constructor(
private usersService: UserService,
private authService: AuthService,
private configService: ConfigService,
) {
super({
clientID: configService.get('INFRA.GOOGLE_CLIENT_ID'),
clientSecret: configService.get('INFRA.GOOGLE_CLIENT_SECRET'),
callbackURL: configService.get('INFRA.GOOGLE_CALLBACK_URL'),
scope: configService.get('INFRA.GOOGLE_SCOPE').split(','),
passReqToCallback: true,
store: true,
});
}
async validate(
req: Request,
accessToken,
refreshToken,
profile,
done: VerifyCallback,
) {
const user = await this.usersService.findUserByEmail(
profile.emails[0].value,
);
if (O.isNone(user)) {
const createdUser = await this.usersService.createUserSSO(
accessToken,
refreshToken,
profile,
);
return createdUser;
}
/**
* * displayName and photoURL maybe null if user logged-in via magic-link before SSO
*/
if (!user.value.displayName || !user.value.photoURL) {
const updatedUser = await this.usersService.updateUserDetails(
user.value,
profile,
);
if (E.isLeft(updatedUser)) {
throw new UnauthorizedException(updatedUser.left);
}
}
/**
* * Check to see if entry for Google is present in the Account table for user
* * If user was created with another provider findUserByEmail may return true
*/
const providerAccountExists =
await this.authService.checkIfProviderAccountExists(user.value, profile);
if (O.isNone(providerAccountExists))
await this.usersService.createProviderAccount(
user.value,
accessToken,
refreshToken,
profile,
);
return user.value;
}
}

View File

@@ -1,50 +0,0 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import {
Injectable,
ForbiddenException,
UnauthorizedException,
} from '@nestjs/common';
import { AccessTokenPayload } from 'src/types/AuthTokens';
import { UserService } from 'src/user/user.service';
import { AuthService } from '../auth.service';
import { Request } from 'express';
import * as O from 'fp-ts/Option';
import {
COOKIES_NOT_FOUND,
INVALID_ACCESS_TOKEN,
USER_NOT_FOUND,
} from 'src/errors';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private usersService: UserService,
private configService: ConfigService,
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => {
const ATCookie = request.cookies['access_token'];
if (!ATCookie) {
throw new ForbiddenException(COOKIES_NOT_FOUND);
}
return ATCookie;
},
]),
secretOrKey: configService.get('JWT_SECRET'),
});
}
async validate(payload: AccessTokenPayload) {
if (!payload) throw new ForbiddenException(INVALID_ACCESS_TOKEN);
const user = await this.usersService.findUserById(payload.sub);
if (O.isNone(user)) {
throw new UnauthorizedException(USER_NOT_FOUND);
}
return user.value;
}
}

View File

@@ -1,71 +0,0 @@
import { Strategy } from 'passport-microsoft';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../auth.service';
import { UserService } from 'src/user/user.service';
import * as O from 'fp-ts/Option';
import * as E from 'fp-ts/Either';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class MicrosoftStrategy extends PassportStrategy(Strategy) {
constructor(
private authService: AuthService,
private usersService: UserService,
private configService: ConfigService,
) {
super({
clientID: configService.get('INFRA.MICROSOFT_CLIENT_ID'),
clientSecret: configService.get('INFRA.MICROSOFT_CLIENT_SECRET'),
callbackURL: configService.get('INFRA.MICROSOFT_CALLBACK_URL'),
scope: [configService.get('INFRA.MICROSOFT_SCOPE')],
tenant: configService.get('INFRA.MICROSOFT_TENANT'),
store: true,
});
}
async validate(accessToken: string, refreshToken: string, profile, done) {
const user = await this.usersService.findUserByEmail(
profile.emails[0].value,
);
if (O.isNone(user)) {
const createdUser = await this.usersService.createUserSSO(
accessToken,
refreshToken,
profile,
);
return createdUser;
}
/**
* * displayName and photoURL maybe null if user logged-in via magic-link before SSO
*/
if (!user.value.displayName || !user.value.photoURL) {
const updatedUser = await this.usersService.updateUserDetails(
user.value,
profile,
);
if (E.isLeft(updatedUser)) {
throw new UnauthorizedException(updatedUser.left);
}
}
/**
* * Check to see if entry for Microsoft is present in the Account table for user
* * If user was created with another provider findUserByEmail may return true
*/
const providerAccountExists =
await this.authService.checkIfProviderAccountExists(user.value, profile);
if (O.isNone(providerAccountExists))
await this.usersService.createProviderAccount(
user.value,
accessToken,
refreshToken,
profile,
);
return user.value;
}
}

View File

@@ -1,49 +0,0 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import {
Injectable,
ForbiddenException,
UnauthorizedException,
} from '@nestjs/common';
import { UserService } from 'src/user/user.service';
import { Request } from 'express';
import { RefreshTokenPayload } from 'src/types/AuthTokens';
import {
COOKIES_NOT_FOUND,
INVALID_REFRESH_TOKEN,
USER_NOT_FOUND,
} from 'src/errors';
import * as O from 'fp-ts/Option';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
constructor(
private usersService: UserService,
private configService: ConfigService,
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => {
const RTCookie = request.cookies['refresh_token'];
if (!RTCookie) {
throw new ForbiddenException(COOKIES_NOT_FOUND);
}
return RTCookie;
},
]),
secretOrKey: configService.get('JWT_SECRET'),
});
}
async validate(payload: RefreshTokenPayload) {
if (!payload) throw new ForbiddenException(INVALID_REFRESH_TOKEN);
const user = await this.usersService.findUserById(payload.sub);
if (O.isNone(user)) {
throw new UnauthorizedException(USER_NOT_FOUND);
}
return user.value;
}
}

View File

@@ -1,10 +0,0 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
export const GqlUser = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context);
const { req, headers } = ctx.getContext();
return headers ? headers.user : req.user;
},
);

View File

@@ -1,12 +0,0 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
/**
** Decorator to fetch refresh_token from cookie
*/
export const RTCookie = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req.cookies['refresh_token'];
},
);

View File

@@ -1,763 +0,0 @@
export const INVALID_EMAIL = 'invalid/email' as const;
export const EMAIL_FAILED = 'email/failed' as const;
export const DUPLICATE_EMAIL = 'email/both_emails_cannot_be_same' as const;
/**
* Only one admin account found in infra
* (AdminService)
*/
export const ONLY_ONE_ADMIN_ACCOUNT =
'admin/only_one_admin_account_found' as const;
/**
* Admin user can not be deleted
* To delete the admin user, first make the Admin user a normal user
* (AdminService)
*/
export const ADMIN_CAN_NOT_BE_DELETED =
'admin/admin_can_not_be_deleted' as const;
/**
* Token Authorization failed (Check 'Authorization' Header)
* (GqlAuthGuard)
*/
export const AUTH_FAIL = 'auth/fail';
/**
* Invalid JSON
* (Utils)
*/
export const JSON_INVALID = 'json_invalid';
/**
* Auth Provider not specified
* (Auth)
*/
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
*/
export const ENV_NOT_FOUND_KEY_AUTH_PROVIDERS =
'"VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file';
/**
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is empty in .env file
*/
export const ENV_EMPTY_AUTH_PROVIDERS =
'"VITE_ALLOWED_AUTH_PROVIDERS" is empty in .env file';
/**
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" contains unsupported provider in .env file
*/
export const ENV_NOT_SUPPORT_AUTH_PROVIDERS =
'"VITE_ALLOWED_AUTH_PROVIDERS" contains an unsupported auth provider in .env file';
/**
* Tried to delete a user data document from fb firestore but failed.
* (FirebaseService)
*/
export const USER_FB_DOCUMENT_DELETION_FAILED =
'fb/firebase_document_deletion_failed' as const;
/**
* Tried to do an action on a user where user is not found
*/
export const USER_NOT_FOUND = 'user/not_found' as const;
/**
* User is already invited by admin
*/
export const USER_ALREADY_INVITED = 'admin/user_already_invited' as const;
/**
* User update failure
* (UserService)
*/
export const USER_UPDATE_FAILED = 'user/update_failed' as const;
/**
* User display name validation failure
* (UserService)
*/
export const USER_SHORT_DISPLAY_NAME = 'user/short_display_name' as const;
/**
* User deletion failure
* (UserService)
*/
export const USER_DELETION_FAILED = 'user/deletion_failed' as const;
/**
* Users not found
* (UserService)
*/
export const USERS_NOT_FOUND = 'user/users_not_found' as const;
/**
* User deletion failure error due to user being a team owner
* (UserService)
*/
export const USER_IS_OWNER = 'user/is_owner' as const;
/**
* User deletion failure error due to user being an admin
* (UserService)
*/
export const USER_IS_ADMIN = 'user/is_admin' as const;
/**
* User invite deletion failure error due to invitation not found
* (AdminService)
*/
export const USER_INVITATION_DELETION_FAILED =
'user/invitation_deletion_failed' as const;
/**
* Teams not found
* (TeamsService)
*/
export const TEAMS_NOT_FOUND = 'user/teams_not_found' as const;
/**
* Tried to find user collection but failed
* (UserRequestService)
*/
export const USER_COLLECTION_NOT_FOUND = 'user_collection/not_found' as const;
/**
* Tried to reorder user request but failed
* (UserRequestService)
*/
export const USER_REQUEST_CREATION_FAILED =
'user_request/creation_failed' as const;
/**
* Tried to do an action on a user request but user request is not matched with user collection
* (UserRequestService)
*/
export const USER_REQUEST_INVALID_TYPE = 'user_request/type_mismatch' as const;
/**
* Tried to do an action on a user request where user request is not found
* (UserRequestService)
*/
export const USER_REQUEST_NOT_FOUND = 'user_request/not_found' as const;
/**
* Tried to reorder user request but failed
* (UserRequestService)
*/
export const USER_REQUEST_REORDERING_FAILED =
'user_request/reordering_failed' as const;
/**
* Tried to perform action on a team which they are not a member of
* (GqlTeamMemberGuard)
*/
export const TEAM_MEMBER_NOT_FOUND = 'team/member_not_found' as const;
/**
* Tried to perform action on a team that doesn't accept their member role level
* (GqlTeamMemberGuard)
*/
export const TEAM_NOT_REQUIRED_ROLE = 'team/not_required_role' as const;
/**
* Team name validation failure
* (TeamService)
*/
export const TEAM_NAME_INVALID = 'team/name_invalid';
/**
* Couldn't find the sync data from the user
* (TeamCollectionService)
*/
export const TEAM_USER_NO_FB_SYNCDATA = 'team/user_no_fb_syncdata';
/**
* There was a problem resolving the firebase collection path
* (TeamCollectionService)
*/
export const TEAM_FB_COLL_PATH_RESOLVE_FAIL = 'team/fb_coll_path_resolve_fail';
/**
* Could not find the team in the database
* (TeamCollectionService)
*/
export const TEAM_COLL_NOT_FOUND = 'team_coll/collection_not_found';
/**
* Cannot make parent collection a child of a collection that a child of itself
* (TeamCollectionService)
*/
export const TEAM_COLL_IS_PARENT_COLL = 'team_coll/collection_is_parent_coll';
/**
* Target and Parent collections are not from the same team
* (TeamCollectionService)
*/
export const TEAM_COLL_NOT_SAME_TEAM = 'team_coll/collections_not_same_team';
/**
* Target and Parent collections are the same
* (TeamCollectionService)
*/
export const TEAM_COLL_DEST_SAME =
'team_coll/target_and_destination_collection_are_same';
/**
* Collection is already a root collection
* (TeamCollectionService)
*/
export const TEAM_COL_ALREADY_ROOT =
'team_coll/target_collection_is_already_root_collection';
/**
* Collections have different parents
* (TeamCollectionService)
*/
export const TEAM_COL_NOT_SAME_PARENT =
'team_coll/team_collections_have_different_parents';
/**
* Collection and next Collection are the same
* (TeamCollectionService)
*/
export const TEAM_COL_SAME_NEXT_COLL =
'team_coll/collection_and_next_collection_are_same';
/**
* Team Collection search failed
* (TeamCollectionService)
*/
export const TEAM_COL_SEARCH_FAILED = 'team_coll/team_collection_search_failed';
/**
* Team Collection Re-Ordering Failed
* (TeamCollectionService)
*/
export const TEAM_COL_REORDERING_FAILED = 'team_coll/reordering_failed';
/**
* Tried to update the team to a state it doesn't have any owners
* (TeamService)
*/
export const TEAM_ONLY_ONE_OWNER = 'team/only_one_owner';
/**
* Invalid or non-existent Team ID
* (TeamService)
*/
export const TEAM_INVALID_ID = 'team/invalid_id' as const;
/**
* Invalid or non-existent collection id
* (GqlCollectionTeamMemberGuard)
*/
export const TEAM_INVALID_COLL_ID = 'team/invalid_coll_id' as const;
/**
* Invalid team id or user id
* (TeamService)
*/
export const TEAM_INVALID_ID_OR_USER = 'team/invalid_id_or_user';
/**
* The provided title for the team collection is short (less than 3 characters)
* (TeamCollectionService)
*/
export const TEAM_COLL_SHORT_TITLE = 'team_coll/short_title';
/**
* The JSON used is not valid
* (TeamCollectionService)
*/
export const TEAM_COLL_INVALID_JSON = 'team_coll/invalid_json';
/**
* The Team Collection does not belong to the team
* (TeamCollectionService)
*/
export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const;
/**
* The Team Collection data is not valid
* (TeamCollectionService)
*/
export const TEAM_COLL_DATA_INVALID =
'team_coll/team_coll_data_invalid' as const;
/**
* Team Collection parent tree generation failed
* (TeamCollectionService)
*/
export const TEAM_COLL_PARENT_TREE_GEN_FAILED =
'team_coll/team_coll_parent_tree_generation_failed';
/**
* Tried to perform an action on a request that doesn't accept their member role level
* (GqlRequestTeamMemberGuard)
*/
export const TEAM_REQ_NOT_REQUIRED_ROLE = 'team_req/not_required_role';
/**
* Tried to operate on a request which does not exist
* (TeamRequestService)
*/
export const TEAM_REQ_NOT_FOUND = 'team_req/not_found' as const;
/**
* Invalid or non-existent collection id
* (TeamRequestService)
*/
export const TEAM_REQ_INVALID_TARGET_COLL_ID =
'team_req/invalid_target_id' as const;
/**
* Tried to reorder team request but failed
* (TeamRequestService)
*/
export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const;
/**
* Team Request search failed
* (TeamRequestService)
*/
export const TEAM_REQ_SEARCH_FAILED = 'team_req/team_request_search_failed';
/**
* Team Request parent tree generation failed
* (TeamRequestService)
*/
export const TEAM_REQ_PARENT_TREE_GEN_FAILED =
'team_req/team_req_parent_tree_generation_failed';
/**
* No Postmark Sender Email defined
* (AuthService)
*/
export const SENDER_EMAIL_INVALID = 'mailer/sender_email_invalid' as const;
/**
* Tried to perform an action on a request when the user is not even a member of the team
* (GqlRequestTeamMemberGuard, GqlCollectionTeamMemberGuard)
*/
export const TEAM_REQ_NOT_MEMBER = 'team_req/not_member';
export const TEAM_INVITE_MEMBER_HAS_INVITE =
'team_invite/member_has_invite' as const;
export const TEAM_INVITE_NO_INVITE_FOUND =
'team_invite/no_invite_found' as const;
export const TEAM_INVITE_ALREADY_MEMBER = 'team_invite/already_member' as const;
export const TEAM_INVITE_EMAIL_DO_NOT_MATCH =
'team_invite/email_do_not_match' as const;
export const TEAM_INVITE_NOT_VALID_VIEWER =
'team_invite/not_valid_viewer' as const;
/**
* No team invitations found
* (TeamInvitationService)
*/
export const TEAM_INVITATION_NOT_FOUND =
'team_invite/invitations_not_found' as const;
/**
* ShortCode not found in DB
* (ShortcodeService)
*/
export const SHORTCODE_NOT_FOUND = 'shortcode/not_found' as const;
/**
* Invalid or non-existent TEAM ENVIRONMENT ID
* (TeamEnvironmentsService)
*/
export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const;
/**
* Invalid TEAM ENVIRONMENT name
* (TeamEnvironmentsService)
*/
export const TEAM_ENVIRONMENT_SHORT_NAME =
'team_environment/short_name' as const;
/**
* The user is not a member of the team of the given environment
* (GqlTeamEnvTeamGuard)
*/
export const TEAM_ENVIRONMENT_NOT_TEAM_MEMBER =
'team_environment/not_team_member' as const;
/**
* User setting not found for a user
* (UserSettingsService)
*/
export const USER_SETTINGS_NOT_FOUND = 'user_settings/not_found' as const;
/**
* User setting already exists for a user
* (UserSettingsService)
*/
export const USER_SETTINGS_ALREADY_EXISTS =
'user_settings/settings_already_exists' as const;
/**
* User setting invalid (null) settings
* (UserSettingsService)
*/
export const USER_SETTINGS_NULL_SETTINGS =
'user_settings/null_settings' as const;
/*
* Global environment doesn't exist for the user
* (UserEnvironmentsService)
*/
export const USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXISTS =
'user_environment/global_env_does_not_exists' as const;
/**
* Global environment already exists for the user
* (UserEnvironmentsService)
*/
export const USER_ENVIRONMENT_GLOBAL_ENV_EXISTS =
'user_environment/global_env_already_exists' as const;
/*
/**
* User environment doesn't exist for the user
* (UserEnvironmentsService)
*/
export const USER_ENVIRONMENT_ENV_DOES_NOT_EXISTS =
'user_environment/user_env_does_not_exists' as const;
/*
/**
* Cannot delete the global user environment
* (UserEnvironmentsService)
*/
export const USER_ENVIRONMENT_GLOBAL_ENV_DELETION_FAILED =
'user_environment/user_env_global_env_deletion_failed' as const;
/*
/**
* User environment is not a global environment
* (UserEnvironmentsService)
*/
export const USER_ENVIRONMENT_IS_NOT_GLOBAL =
'user_environment/user_env_is_not_global' as const;
/*
/**
* User environment update failed
* (UserEnvironmentsService)
*/
export const USER_ENVIRONMENT_UPDATE_FAILED =
'user_environment/user_env_update_failed' as const;
/*
/**
* User environment invalid environment name
* (UserEnvironmentsService)
*/
export const USER_ENVIRONMENT_INVALID_ENVIRONMENT_NAME =
'user_environment/user_env_invalid_env_name' as const;
/*
/**
* User history not found
* (UserHistoryService)
*/
export const USER_HISTORY_NOT_FOUND = 'user_history/history_not_found' as const;
/*
/**
* Invalid Request Type in History
* (UserHistoryService)
*/
export const USER_HISTORY_INVALID_REQ_TYPE =
'user_history/req_type_invalid' as const;
/*
|------------------------------------|
|Server errors that are actually bugs|
|------------------------------------|
*/
/**
* Couldn't find user data from the GraphQL context (Check if GqlAuthGuard is applied)
* (GqlTeamMemberGuard, GqlCollectionTeamMemberGuard)
*/
export const BUG_AUTH_NO_USER_CTX = 'bug/auth/auth_no_user_ctx' as const;
/**
* Couldn't find teamID parameter in the attached GraphQL operation. (Check if teamID is present)
* (GqlTeamMemberGuard, GQLEAAdminGuard, GqlCollectionTeamMemberGuard)
*/
export const BUG_TEAM_NO_TEAM_ID = 'bug/team/no_team_id';
/**
* Couldn't find RequireTeamRole decorator. (Check if it is applied)
* (GqlTeamMemberGuard)
*/
export const BUG_TEAM_NO_REQUIRE_TEAM_ROLE = 'bug/team/no_require_team_role';
/**
* Couldn't find 'collectionID' param to the attached GQL operation. (Check if exists)
* (GqlCollectionTeamMemberGuard)
*/
export const BUG_TEAM_COLL_NO_COLL_ID = 'bug/team_coll/no_coll_id';
/**
* Couldn't find 'requestID' param to the attached GQL operation. (Check if exists)
* (GqlRequestTeamMemberGuard)
*/
export const BUG_TEAM_REQ_NO_REQ_ID = 'bug/team_req/no_req_id';
export const BUG_TEAM_INVITE_NO_INVITE_ID =
'bug/team_invite/no_invite_id' as const;
/**
* Couldn't find RequireTeamRole decorator. (Check if it is applied)
* (GqlTeamEnvTeamGuard)
*/
export const BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES =
'bug/team_env/guard_no_require_roles' as const;
/**
* Couldn't find 'id' param to the operation. (Check if it is applied)
* (GqlTeamEnvTeamGuard)
*/
export const BUG_TEAM_ENV_GUARD_NO_ENV_ID =
'bug/team_env/guard_no_env_id' as const;
/**
* The data sent to the verify route are invalid
* (AuthService)
*/
export const INVALID_MAGIC_LINK_DATA = 'auth/magic_link_invalid_data' as const;
/**
* Could not find VerificationToken entry in the db
* (AuthService)
*/
export const VERIFICATION_TOKEN_DATA_NOT_FOUND =
'auth/verification_token_data_not_found' as const;
/**
* Auth Tokens expired
* (AuthService)
*/
export const TOKEN_EXPIRED = 'auth/token_expired' as const;
/**
* VerificationToken Tokens expired i.e. magic-link expired
* (AuthService)
*/
export const MAGIC_LINK_EXPIRED = 'auth/magic_link_expired' as const;
/**
* No cookies were found in the auth request
* (AuthService)
*/
export const COOKIES_NOT_FOUND = 'auth/cookies_not_found' as const;
/**
* Access Token is malformed or invalid
* (AuthService)
*/
export const INVALID_ACCESS_TOKEN = 'auth/invalid_access_token' as const;
/**
* Refresh Token is malformed or invalid
* (AuthService)
*/
export const INVALID_REFRESH_TOKEN = 'auth/invalid_refresh_token' as const;
/**
* The provided title for the user collection is short (less than 3 characters)
* (UserCollectionService)
*/
export const USER_COLL_SHORT_TITLE = 'user_coll/short_title' as const;
/**
* User Collection could not be found
* (UserCollectionService)
*/
export const USER_COLL_NOT_FOUND = 'user_coll/not_found' as const;
/**
* UserCollection is already a root collection
* (UserCollectionService)
*/
export const USER_COLL_ALREADY_ROOT =
'user_coll/target_user_collection_is_already_root_user_collection' as const;
/**
* Target and Parent user collections are the same
* (UserCollectionService)
*/
export const USER_COLL_DEST_SAME =
'user_coll/target_and_destination_user_collection_are_same' as const;
/**
* Target and Parent user collections are not from the same user
* (UserCollectionService)
*/
export const USER_COLL_NOT_SAME_USER = 'user_coll/not_same_user' as const;
/**
* Target and Parent user collections are not from the same type
* (UserCollectionService)
*/
export const USER_COLL_NOT_SAME_TYPE = 'user_coll/type_mismatch' as const;
/**
* Cannot make a parent user collection a child of itself
* (UserCollectionService)
*/
export const USER_COLL_IS_PARENT_COLL =
'user_coll/user_collection_is_parent_coll' as const;
/**
* User Collection Re-Ordering Failed
* (UserCollectionService)
*/
export const USER_COLL_REORDERING_FAILED =
'user_coll/reordering_failed' as const;
/**
* The Collection and Next User Collection are the same
* (UserCollectionService)
*/
export const USER_COLL_SAME_NEXT_COLL =
'user_coll/user_collection_and_next_user_collection_are_same' as const;
/**
* The User Collection data is not valid
* (UserCollectionService)
*/
export const USER_COLL_DATA_INVALID =
'user_coll/user_coll_data_invalid' as const;
/**
* The User Collection does not belong to the logged-in user
* (UserCollectionService)
*/
export const USER_NOT_OWNER = 'user_coll/user_not_owner' as const;
/**
* The JSON used is not valid
* (UserCollectionService)
*/
export const USER_COLL_INVALID_JSON = 'user_coll/invalid_json';
/*
* MAILER_SMTP_URL environment variable is not defined
* (MailerModule)
*/
export const MAILER_SMTP_URL_UNDEFINED = 'mailer/smtp_url_undefined' as const;
/**
* MAILER_ADDRESS_FROM environment variable is not defined
* (MailerModule)
*/
export const MAILER_FROM_ADDRESS_UNDEFINED =
'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;

View File

@@ -1,112 +0,0 @@
import { NestFactory } from '@nestjs/core';
import {
GraphQLSchemaBuilderModule,
GraphQLSchemaFactory,
} from '@nestjs/graphql';
import { printSchema } from 'graphql/utilities';
import * as path from 'path';
import * as fs from 'fs';
import { ShortcodeResolver } from './shortcode/shortcode.resolver';
import { TeamCollectionResolver } from './team-collection/team-collection.resolver';
import { TeamEnvironmentsResolver } from './team-environments/team-environments.resolver';
import { TeamInvitationResolver } from './team-invitation/team-invitation.resolver';
import { TeamRequestResolver } from './team-request/team-request.resolver';
import { TeamMemberResolver } from './team/team-member.resolver';
import { TeamResolver } from './team/team.resolver';
import { UserCollectionResolver } from './user-collection/user-collection.resolver';
import { UserEnvironmentsResolver } from './user-environment/user-environments.resolver';
import { UserHistoryResolver } from './user-history/user-history.resolver';
import { UserRequestResolver } from './user-request/resolvers/user-request.resolver';
import { UserSettingsResolver } from './user-settings/user-settings.resolver';
import { UserResolver } from './user/user.resolver';
import { Logger } from '@nestjs/common';
import { AdminResolver } from './admin/admin.resolver';
import { TeamEnvsTeamResolver } from './team-environments/team.resolver';
import { TeamTeamInviteExtResolver } from './team-invitation/team-teaminvite-ext.resolver';
import { UserRequestUserCollectionResolver } from './user-request/resolvers/user-collection.resolver';
import { UserEnvsUserResolver } from './user-environment/user.resolver';
import { UserHistoryUserResolver } from './user-history/user.resolver';
import { UserSettingsUserResolver } from './user-settings/user.resolver';
import { InfraResolver } from './admin/infra.resolver';
/**
* All the resolvers present in the application.
*
* NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate
*/
const RESOLVERS = [
InfraResolver,
AdminResolver,
ShortcodeResolver,
TeamResolver,
TeamEnvsTeamResolver,
TeamMemberResolver,
TeamCollectionResolver,
TeamTeamInviteExtResolver,
TeamEnvironmentsResolver,
TeamEnvsTeamResolver,
TeamInvitationResolver,
TeamRequestResolver,
UserResolver,
UserCollectionResolver,
UserEnvironmentsResolver,
UserEnvsUserResolver,
UserHistoryUserResolver,
UserHistoryResolver,
UserCollectionResolver,
UserRequestResolver,
UserRequestUserCollectionResolver,
UserSettingsResolver,
UserSettingsUserResolver,
];
/**
* All the custom scalars present in the application.
*
* NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate
*/
const SCALARS = [];
/**
* Generates the GraphQL Schema SDL definition and writes it into the location
* specified by the `GQL_SCHEMA_EMIT_LOCATION` environment variable.
*/
export async function emitGQLSchemaFile() {
const logger = new Logger('emitGQLSchemaFile');
try {
const destination = path.resolve(
__dirname,
process.env.GQL_SCHEMA_EMIT_LOCATION ?? '../gen/schema.gql',
);
logger.log(`GQL_SCHEMA_EMIT_LOCATION: ${destination}`);
const app = await NestFactory.create(GraphQLSchemaBuilderModule);
await app.init();
const gqlSchemaFactory = app.get(GraphQLSchemaFactory);
logger.log(
`Generating Schema against ${RESOLVERS.length} resolvers and ${SCALARS.length} custom scalars`,
);
const schema = await gqlSchemaFactory.create(RESOLVERS, SCALARS, {
numberScalarMode: 'integer',
});
const schemaString = printSchema(schema);
logger.log(`Writing schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`);
// Generating folders if required to emit to the given output
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.writeFileSync(destination, schemaString);
logger.log(`Wrote schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`);
} catch (e) {
logger.error(
`Failed writing schema to GQL_SCHEMA_EMIT_LOCATION. Reason: ${e}`,
);
}
}

View File

@@ -1,12 +0,0 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
const { req, headers } = ctx.getContext();
return headers ? headers : req;
}
}

View File

@@ -1,12 +0,0 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { ThrottlerGuard } from '@nestjs/throttler';
@Injectable()
export class GqlThrottlerGuard extends ThrottlerGuard {
getRequestResponse(context: ExecutionContext) {
const gqlCtx = GqlExecutionContext.create(context);
const ctx = gqlCtx.getContext();
return { req: ctx.req, res: ctx.res };
}
}

View File

@@ -1,9 +0,0 @@
import { ThrottlerGuard } from '@nestjs/throttler';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
protected async getTracker(req: Record<string, any>): Promise<string> {
return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs
}
}

View File

@@ -1,245 +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;
}
/**
* Verify if 'infra_config' table is loaded with all entries
* @returns boolean
*/
export async function isInfraConfigTablePopulated(): Promise<boolean> {
const prisma = new PrismaService();
try {
const dbInfraConfigs = await prisma.infraConfig.findMany();
const infraConfigDefaultObjs = await getDefaultInfraConfigs();
const propsRemainingToInsert = infraConfigDefaultObjs.filter(
(p) => !dbInfraConfigs.find((e) => e.name === p.name),
);
if (propsRemainingToInsert.length > 0) {
console.log(
'Infra Config table is not populated with all entries. Populating now...',
);
return false;
}
return true;
} catch (error) {
return false;
}
}
/**
* Stop the app after 5 seconds
* (Docker will re-start the app)
*/
export function stopApp() {
console.log('Stopping app in 5 seconds...');
setTimeout(() => {
console.log('Stopping app now...');
process.kill(process.pid, 'SIGTERM');
}, 5000);
}
/**
* Get the configured SSO providers
* @returns Array of configured SSO providers
*/
export function getConfiguredSSOProviders() {
const allowedAuthProviders: string[] =
process.env.VITE_ALLOWED_AUTH_PROVIDERS.split(',');
let configuredAuthProviders: string[] = [];
const addProviderIfConfigured = (provider) => {
const configParameters: string[] = AuthProviderConfigurations[provider];
const isConfigured = configParameters.every((configParameter) => {
return process.env[configParameter];
});
if (isConfigured) configuredAuthProviders.push(provider);
};
allowedAuthProviders.forEach((provider) => addProviderIfConfigured(provider));
if (configuredAuthProviders.length === 0) {
throwErr(AUTH_PROVIDER_NOT_CONFIGURED);
} else if (allowedAuthProviders.length !== configuredAuthProviders.length) {
const unConfiguredAuthProviders = allowedAuthProviders.filter(
(provider) => {
return !configuredAuthProviders.includes(provider);
},
);
console.log(
`${unConfiguredAuthProviders.join(
',',
)} SSO auth provider(s) are not configured properly. Do configure them from Admin Dashboard.`,
);
}
return configuredAuthProviders.join(',');
}
/**
* Generate a hashed valued for analytics
* @returns Generated hashed value
*/
export function generateAnalyticsUserId() {
const hashedUserID = randomBytes(20).toString('hex');
return hashedUserID;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,424 +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, 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 {
// Fetch the default values (value in .env) for configs to be saved in 'infra_config' table
const infraConfigDefaultObjs = await getDefaultInfraConfigs();
// Eliminate the rows (from 'infraConfigDefaultObjs') that are already present in the database table
const dbInfraConfigs = await this.prisma.infraConfig.findMany();
const propsToInsert = infraConfigDefaultObjs.filter(
(p) => !dbInfraConfigs.find((e) => e.name === p.name),
);
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
* @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);
}
}

View File

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

View File

@@ -1,24 +0,0 @@
export type MailDescription = {
template: 'team-invitation';
variables: {
invitee: string;
invite_team_name: string;
action_url: string;
};
};
export type UserMagicLinkMailDescription = {
template: 'user-invitation';
variables: {
inviteeEmail: string;
magicLink: string;
};
};
export type AdminUserInvitationMailDescription = {
template: 'user-invitation';
variables: {
inviteeEmail: string;
magicLink: string;
};
};

View File

@@ -1,48 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { MailerModule as NestMailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { MailerService } from './mailer.service';
import { throwErr } from 'src/utils';
import {
MAILER_FROM_ADDRESS_UNDEFINED,
MAILER_SMTP_URL_UNDEFINED,
} from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { loadInfraConfiguration } from 'src/infra-config/helper';
@Global()
@Module({
imports: [],
providers: [MailerService],
exports: [MailerService],
})
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(),
},
}),
],
};
}
}

View File

@@ -1,79 +0,0 @@
import { Injectable } from '@nestjs/common';
import {
AdminUserInvitationMailDescription,
MailDescription,
UserMagicLinkMailDescription,
} from './MailDescriptions';
import { throwErr } from 'src/utils';
import { EMAIL_FAILED } from 'src/errors';
import { MailerService as NestMailerService } from '@nestjs-modules/mailer';
@Injectable()
export class MailerService {
constructor(private readonly nestMailerService: NestMailerService) {}
/**
* Takes an input mail description and spits out the Email subject required for it
* @param mailDesc The mail description to get subject for
* @returns The subject of the email
*/
private resolveSubjectForMailDesc(
mailDesc:
| MailDescription
| UserMagicLinkMailDescription
| AdminUserInvitationMailDescription,
): string {
switch (mailDesc.template) {
case 'team-invitation':
return `A user has invited you to join a team workspace in Hoppscotch`;
case 'user-invitation':
return 'Sign in to Hoppscotch';
}
}
/**
* Sends an email to the given email address given a mail description
* @param to Receiver's email id
* @param mailDesc Definition of what email to be sent
* @returns Response if email was send successfully or not
*/
async sendEmail(
to: string,
mailDesc: MailDescription | UserMagicLinkMailDescription,
) {
try {
await this.nestMailerService.sendMail({
to,
template: mailDesc.template,
subject: this.resolveSubjectForMailDesc(mailDesc),
context: mailDesc.variables,
});
} catch (error) {
return throwErr(EMAIL_FAILED);
}
}
/**
*
* @param to Receiver's email id
* @param mailDesc Details of email to be sent for user invitation
* @returns Response if email was send successfully or not
*/
async sendUserInvitationEmail(
to: string,
mailDesc: AdminUserInvitationMailDescription,
) {
try {
const res = await this.nestMailerService.sendMail({
to,
template: mailDesc.template,
subject: this.resolveSubjectForMailDesc(mailDesc),
context: mailDesc.variables,
});
return res;
} catch (error) {
return throwErr(EMAIL_FAILED);
}
}
}

View File

@@ -1,526 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<title></title>
<!--
The style block is collapsed on page load to save you some scrolling.
Postmark automatically inlines all CSS properties for maximum email client
compatibility. You can just update styles here, and Postmark does the rest.
-->
<style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a.nohighlight {
color: inherit !important;
text-decoration: none !important;
cursor: default !important;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
font-size: 16px;
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
font-size: 14px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
blockquote {
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
font-size: 13px;
}
/* Utilities ------------------------------ */
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
/* Buttons ------------------------------ */
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
.button--red {
background-color: #FF6136;
border-top: 10px solid #FF6136;
border-right: 18px solid #FF6136;
border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
/* Attribute list ------------------------------ */
.attributes {
margin: 0 0 21px;
}
.attributes_content {
background-color: #F4F4F7;
padding: 16px;
}
.attributes_item {
padding: 0;
}
/* Related Items ------------------------------ */
.related {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.related_item {
padding: 10px 0;
color: #CBCCCF;
font-size: 15px;
line-height: 18px;
}
.related_item-title {
display: block;
margin: .5em 0 0;
}
.related_item-thumb {
display: block;
padding-bottom: 10px;
}
.related_heading {
border-top: 1px solid #CBCCCF;
text-align: center;
padding: 25px 0 10px;
}
/* Discount Code ------------------------------ */
.discount {
width: 100%;
margin: 0;
padding: 24px;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F4F4F7;
border: 2px dashed #CBCCCF;
}
.discount_heading {
text-align: center;
}
.discount_body {
text-align: center;
font-size: 15px;
}
/* Social Icons ------------------------------ */
.social {
width: auto;
}
.social td {
padding: 0;
width: auto;
}
.social_icon {
height: 20px;
margin: 0 8px 10px 8px;
padding: 0;
}
/* Data table ------------------------------ */
.purchase {
width: 100%;
margin: 0;
padding: 35px 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_content {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_item {
padding: 10px 0;
color: #51545E;
font-size: 15px;
line-height: 18px;
}
.purchase_heading {
padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC;
}
.purchase_heading p {
margin: 0;
color: #85878E;
font-size: 12px;
}
.purchase_footer {
padding-top: 15px;
border-top: 1px solid #EAEAEC;
}
.purchase_total {
margin: 0;
text-align: right;
font-weight: bold;
color: #333333;
}
.purchase_total--label {
padding: 0 15px 0 0;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
p {
color: #51545E;
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F2F4F6;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
/* Masthead ----------------------- */
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead_logo {
width: 94px;
}
.email-masthead_name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #FFFFFF;
}
.email-footer {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #A8AAAF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 45px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,
.email-body_inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
background-color: #333333 !important;
color: #FFF !important;
}
p,
ul,
ol,
blockquote,
h1,
h2,
h3,
span,
.purchase_item {
color: #FFF !important;
}
.attributes_content,
.discount {
background-color: #222 !important;
}
.email-masthead_name {
text-shadow: none !important;
}
}
:root {
color-scheme: light dark;
supported-color-schemes: light dark;
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
</head>
<body>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="https://hoppscotch.io" class="f-fallback email-masthead_name">
Hoppscotch
</a>
</td>
</tr>
<!-- Email Body -->
<tr>
<td class="email-body" width="570" cellpadding="0" cellspacing="0">
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<!-- Body content -->
<tr>
<td class="content-cell">
<div class="f-fallback">
<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>
<!-- Action -->
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<!-- Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td>
<a href="{{action_url}}" class="button button--green" target="_blank">Join {{invite_team_name}}</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>
Welcome aboard, <br />
Your friends at Hoppscotch
</p>
<p><strong>P.S.</strong> If you don't associate with <a class="nohighlight" name="invitee" href="#">{{invitee}}</a> or <a class="nohighlight" name="invite_team_name" href="#">{{invite_team_name}}</a>, just ignore this email.</p>
<!-- Sub copy -->
<table class="body-sub">
<tr>
<td>
<p class="sub">If youre having trouble with the button above, copy and paste the URL below into your web browser.</p>
<p class="sub">{{action_url}}</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; 2021 Hoppscotch</p>
<p class="f-fallback sub align-center">12 New Fetter Lane, London, United Kingdom, EC4A 1JP.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

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