Compare commits

..

11 Commits

Author SHA1 Message Date
Liyas Thomas
502da61b8b fix: ui styles 2023-12-07 14:43:22 +05:30
nivedin
f2777a9a75 chore: cleanup 2023-12-07 14:43:22 +05:30
nivedin
4bd3e89f89 refactor: update embed customize flow 2023-12-07 14:43:22 +05:30
nivedin
09e9601940 chore: add save option to modify sahred request 2023-12-07 14:43:22 +05:30
nivedin
fd4a5c626f chore: disable method and input 2023-12-07 14:43:22 +05:30
nivedin
67cfef82af chore: cleanup 2023-12-07 14:43:22 +05:30
nivedin
aa18249791 chore: use shared request properties for embed 2023-12-07 14:43:22 +05:30
nivedin
9d8fdb4d04 chore: add doc button if response is null 2023-12-07 14:43:22 +05:30
nivedin
fbca9b06c3 chore: add open app link 2023-12-07 14:43:22 +05:30
nivedin
bb0bf35164 feat: added embeds components wip 2023-12-07 14:43:22 +05:30
nivedin
5a35c098ec feat: mutations and small refactor in share req 2023-12-07 14:43:22 +05:30
721 changed files with 36984 additions and 50097 deletions

View File

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

5
.gitignore vendored
View File

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

1
.npmrc
View File

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

View File

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

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

@@ -66,7 +66,6 @@ services:
# The service that spins up all 3 services at once in one container # The service that spins up all 3 services at once in one container
hoppscotch-aio: hoppscotch-aio:
container_name: hoppscotch-aio container_name: hoppscotch-aio
restart: unless-stopped
build: build:
dockerfile: prod.Dockerfile dockerfile: prod.Dockerfile
context: . context: .
@@ -118,11 +117,11 @@ services:
restart: always restart: always
environment: 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) # 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 - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
- PORT=3000 - PORT=3000
volumes: volumes:
# Uncomment the line below when modifying code. Only applicable when using the "dev" target. # Uncomment the line below when modifying code. Only applicable when using the "dev" target.
- ./packages/hoppscotch-backend/:/usr/src/app # - ./packages/hoppscotch-backend/:/usr/src/app
- /usr/src/app/node_modules/ - /usr/src/app/node_modules/
depends_on: depends_on:
hoppscotch-db: hoppscotch-db:

View File

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

View File

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

View File

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

24
packages/dioc/.gitignore vendored Normal file
View File

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

141
packages/dioc/README.md Normal file
View File

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

2
packages/dioc/index.d.ts vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

34
packages/dioc/lib/vue.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

2
packages/dioc/testing.d.ts vendored Normal file
View File

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

View File

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

View File

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

View File

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

2
packages/dioc/vue.d.ts vendored Normal file
View File

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

View File

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

View File

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

View File

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

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,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,2 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "lastLoggedOn" TIMESTAMP(3);

View File

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

View File

@@ -104,11 +104,9 @@ model User {
userRequests UserRequest[] userRequests UserRequest[]
currentRESTSession Json? currentRESTSession Json?
currentGQLSession Json? currentGQLSession Json?
lastLoggedOn DateTime?
createdOn DateTime @default(now()) @db.Timestamp(3) createdOn DateTime @default(now()) @db.Timestamp(3)
invitedUsers InvitedUsers[] invitedUsers InvitedUsers[]
shortcodes Shortcode[] shortcodes Shortcode[]
personalAccessTokens PersonalAccessToken[]
} }
model Account { model Account {
@@ -211,23 +209,3 @@ enum TeamMemberRole {
VIEWER VIEWER
EDITOR EDITOR
} }
model InfraConfig {
id String @id @default(cuid())
name String @unique
value String?
active Boolean @default(true) // Use case: Let's say, Admin wants to disable Google SSO, but doesn't want to delete the config
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}
model PersonalAccessToken {
id String @id @default(cuid())
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
label String
token String @unique @default(uuid())
expiresOn DateTime? @db.Timestamp(3)
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,12 +1,5 @@
import { UseGuards } from '@nestjs/common'; import { UseGuards } from '@nestjs/common';
import { import { Args, ID, Query, ResolveField, Resolver } from '@nestjs/graphql';
Args,
ID,
Mutation,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { Infra } from './infra.model'; import { Infra } from './infra.model';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
@@ -17,31 +10,17 @@ import { AuthUser } from 'src/types/AuthUser';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { Admin } from './admin.model'; import { Admin } from './admin.model';
import { import { PaginationArgs } from 'src/types/input-types.args';
OffsetPaginationArgs,
PaginationArgs,
} from 'src/types/input-types.args';
import { InvitedUser } from './invited-user.model'; import { InvitedUser } from './invited-user.model';
import { Team } from 'src/team/team.model'; import { Team } from 'src/team/team.model';
import { TeamInvitation } from 'src/team-invitation/team-invitation.model'; import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
import { GqlAdmin } from './decorators/gql-admin.decorator'; import { GqlAdmin } from './decorators/gql-admin.decorator';
import { ShortcodeWithUserEmail } from 'src/shortcode/shortcode.model'; 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) @UseGuards(GqlThrottlerGuard)
@Resolver(() => Infra) @Resolver(() => Infra)
export class InfraResolver { export class InfraResolver {
constructor( constructor(private adminService: AdminService) {}
private adminService: AdminService,
private infraConfigService: InfraConfigService,
) {}
@Query(() => Infra, { @Query(() => Infra, {
description: 'Fetch details of the Infrastructure', description: 'Fetch details of the Infrastructure',
@@ -80,7 +59,6 @@ export class InfraResolver {
@ResolveField(() => [User], { @ResolveField(() => [User], {
description: 'Returns a list of all the users in infra', description: 'Returns a list of all the users in infra',
deprecationReason: 'Use allUsersV2 instead',
}) })
@UseGuards(GqlAuthGuard, GqlAdminGuard) @UseGuards(GqlAuthGuard, GqlAdminGuard)
async allUsers(@Args() args: PaginationArgs): Promise<AuthUser[]> { async allUsers(@Args() args: PaginationArgs): Promise<AuthUser[]> {
@@ -88,33 +66,11 @@ export class InfraResolver {
return users; 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], { @ResolveField(() => [InvitedUser], {
description: 'Returns a list of all the invited users', description: 'Returns a list of all the invited users',
}) })
async invitedUsers( async invitedUsers(): Promise<InvitedUser[]> {
@Args() args: OffsetPaginationArgs, const users = await this.adminService.fetchInvitedUsers();
): Promise<InvitedUser[]> {
const users = await this.adminService.fetchInvitedUsers(args);
return users; return users;
} }
@@ -266,97 +222,4 @@ export class InfraResolver {
userEmail, 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

@@ -20,31 +20,14 @@ import { ShortcodeModule } from './shortcode/shortcode.module';
import { COOKIES_NOT_FOUND } from './errors'; import { COOKIES_NOT_FOUND } from './errors';
import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { InfraConfigModule } from './infra-config/infra-config.module';
import { loadInfraConfiguration } from './infra-config/helper';
import { MailerModule } from './mailer/mailer.module';
import { PosthogModule } from './posthog/posthog.module';
import { ScheduleModule } from '@nestjs/schedule';
import { HealthModule } from './health/health.module';
import { AccessTokenModule } from './access-token/access-token.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ GraphQLModule.forRoot<ApolloDriverConfig>({
isGlobal: true,
load: [async () => loadInfraConfiguration()],
}),
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
return {
buildSchemaOptions: { buildSchemaOptions: {
numberScalarMode: 'integer', numberScalarMode: 'integer',
}, },
playground: configService.get('PRODUCTION') !== 'true', playground: process.env.PRODUCTION !== 'true',
autoSchemaFile: true, autoSchemaFile: true,
installSubscriptionHandlers: true, installSubscriptionHandlers: true,
subscriptions: { subscriptions: {
@@ -55,6 +38,7 @@ import { AccessTokenModule } from './access-token/access-token.module';
const cookies = subscriptionContextCookieParser( const cookies = subscriptionContextCookieParser(
websocket.upgradeReq.headers.cookie, websocket.upgradeReq.headers.cookie,
); );
return { return {
headers: { ...websocket?.upgradeReq?.headers, cookies }, headers: { ...websocket?.upgradeReq?.headers, cookies },
}; };
@@ -71,22 +55,16 @@ import { AccessTokenModule } from './access-token/access-token.module';
res, res,
connection, connection,
}), }),
}; driver: ApolloDriver,
},
}), }),
ThrottlerModule.forRootAsync({ ThrottlerModule.forRoot([
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => [
{ {
ttl: +configService.get('RATE_LIMIT_TTL'), ttl: +process.env.RATE_LIMIT_TTL,
limit: +configService.get('RATE_LIMIT_MAX'), limit: +process.env.RATE_LIMIT_MAX,
}, },
], ]),
}),
MailerModule.register(),
UserModule, UserModule,
AuthModule.register(), AuthModule,
AdminModule, AdminModule,
UserSettingsModule, UserSettingsModule,
UserEnvironmentsModule, UserEnvironmentsModule,
@@ -99,11 +77,6 @@ import { AccessTokenModule } from './access-token/access-token.module';
TeamInvitationModule, TeamInvitationModule,
UserCollectionModule, UserCollectionModule,
ShortcodeModule, ShortcodeModule,
InfraConfigModule,
PosthogModule,
ScheduleModule.forRoot(),
HealthModule,
AccessTokenModule,
], ],
providers: [GQLComplexityPlugin], providers: [GQLComplexityPlugin],
controllers: [AppController], controllers: [AppController],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,14 +10,6 @@ export const DUPLICATE_EMAIL = 'email/both_emails_cannot_be_same' as const;
export const ONLY_ONE_ADMIN_ACCOUNT = export const ONLY_ONE_ADMIN_ACCOUNT =
'admin/only_one_admin_account_found' as const; 'admin/only_one_admin_account_found' as const;
/**
* Admin user can not be deleted
* To delete the admin user, first make the Admin user a normal user
* (AdminService)
*/
export const ADMIN_CAN_NOT_BE_DELETED =
'admin/admin_can_not_be_deleted' as const;
/** /**
* Token Authorization failed (Check 'Authorization' Header) * Token Authorization failed (Check 'Authorization' Header)
* (GqlAuthGuard) * (GqlAuthGuard)
@@ -36,13 +28,6 @@ export const JSON_INVALID = 'json_invalid';
*/ */
export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified'; export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified';
/**
* Auth Provider not specified
* (Auth)
*/
export const AUTH_PROVIDER_NOT_CONFIGURED =
'auth/provider_not_configured_correctly';
/** /**
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file * Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file
*/ */
@@ -84,12 +69,6 @@ export const USER_ALREADY_INVITED = 'admin/user_already_invited' as const;
*/ */
export const USER_UPDATE_FAILED = 'user/update_failed' as const; export const USER_UPDATE_FAILED = 'user/update_failed' as const;
/**
* User display name validation failure
* (UserService)
*/
export const USER_SHORT_DISPLAY_NAME = 'user/short_display_name' as const;
/** /**
* User deletion failure * User deletion failure
* (UserService) * (UserService)
@@ -113,13 +92,6 @@ export const USER_IS_OWNER = 'user/is_owner' as const;
*/ */
export const USER_IS_ADMIN = 'user/is_admin' as const; export const USER_IS_ADMIN = 'user/is_admin' as const;
/**
* User invite deletion failure error due to invitation not found
* (AdminService)
*/
export const USER_INVITATION_DELETION_FAILED =
'user/invitation_deletion_failed' as const;
/** /**
* Teams not found * Teams not found
* (TeamsService) * (TeamsService)
@@ -234,12 +206,6 @@ export const TEAM_COL_NOT_SAME_PARENT =
export const TEAM_COL_SAME_NEXT_COLL = export const TEAM_COL_SAME_NEXT_COLL =
'team_coll/collection_and_next_collection_are_same'; 'team_coll/collection_and_next_collection_are_same';
/**
* Team Collection search failed
* (TeamCollectionService)
*/
export const TEAM_COL_SEARCH_FAILED = 'team_coll/team_collection_search_failed';
/** /**
* Team Collection Re-Ordering Failed * Team Collection Re-Ordering Failed
* (TeamCollectionService) * (TeamCollectionService)
@@ -295,13 +261,6 @@ export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const;
export const TEAM_COLL_DATA_INVALID = export const TEAM_COLL_DATA_INVALID =
'team_coll/team_coll_data_invalid' as const; 'team_coll/team_coll_data_invalid' as const;
/**
* Team Collection parent tree generation failed
* (TeamCollectionService)
*/
export const TEAM_COLL_PARENT_TREE_GEN_FAILED =
'team_coll/team_coll_parent_tree_generation_failed';
/** /**
* Tried to perform an action on a request that doesn't accept their member role level * Tried to perform an action on a request that doesn't accept their member role level
* (GqlRequestTeamMemberGuard) * (GqlRequestTeamMemberGuard)
@@ -327,19 +286,6 @@ export const TEAM_REQ_INVALID_TARGET_COLL_ID =
*/ */
export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const; export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const;
/**
* Team Request search failed
* (TeamRequestService)
*/
export const TEAM_REQ_SEARCH_FAILED = 'team_req/team_request_search_failed';
/**
* Team Request parent tree generation failed
* (TeamRequestService)
*/
export const TEAM_REQ_PARENT_TREE_GEN_FAILED =
'team_req/team_req_parent_tree_generation_failed';
/** /**
* No Postmark Sender Email defined * No Postmark Sender Email defined
* (AuthService) * (AuthService)
@@ -698,102 +644,3 @@ export const SHORTCODE_INVALID_PROPERTIES_JSON =
*/ */
export const SHORTCODE_PROPERTIES_NOT_FOUND = export const SHORTCODE_PROPERTIES_NOT_FOUND =
'shortcode/properties_not_found' as const; 'shortcode/properties_not_found' as const;
/**
* Infra Config not found
* (InfraConfigService)
*/
export const INFRA_CONFIG_NOT_FOUND = 'infra_config/not_found' as const;
/**
* Infra Config update failed
* (InfraConfigService)
*/
export const INFRA_CONFIG_UPDATE_FAILED = 'infra_config/update_failed' as const;
/**
* Infra Config not listed for onModuleInit creation
* (InfraConfigService)
*/
export const INFRA_CONFIG_NOT_LISTED =
'infra_config/properly_not_listed' as const;
/**
* Infra Config reset failed
* (InfraConfigService)
*/
export const INFRA_CONFIG_RESET_FAILED = 'infra_config/reset_failed' as const;
/**
* Infra Config invalid input for Config variable
* (InfraConfigService)
*/
export const INFRA_CONFIG_INVALID_INPUT = 'infra_config/invalid_input' as const;
/**
* Infra Config service (auth provider/mailer/audit logs) not configured
* (InfraConfigService)
*/
export const INFRA_CONFIG_SERVICE_NOT_CONFIGURED =
'infra_config/service_not_configured' as const;
/**
* Infra Config update/fetch operation not allowed
* (InfraConfigService)
*/
export const INFRA_CONFIG_OPERATION_NOT_ALLOWED =
'infra_config/operation_not_allowed';
/**
* Error message for when the database table does not exist
* (InfraConfigService)
*/
export const DATABASE_TABLE_NOT_EXIST =
'Database migration not found. Please check the documentation for assistance: https://docs.hoppscotch.io/documentation/self-host/community-edition/install-and-build#running-migrations';
/**
* PostHog client is not initialized
* (InfraConfigService)
*/
export const POSTHOG_CLIENT_NOT_INITIALIZED = 'posthog/client_not_initialized';
/**
* Inputs supplied are invalid
*/
export const INVALID_PARAMS = 'invalid_parameters' as const;
/**
* The provided label for the access-token is short (less than 3 characters)
* (AccessTokenService)
*/
export const ACCESS_TOKEN_LABEL_SHORT = 'access_token/label_too_short';
/**
* The provided expiryInDays value is not valid
* (AccessTokenService)
*/
export const ACCESS_TOKEN_EXPIRY_INVALID = 'access_token/expiry_days_invalid';
/**
* The provided PAT ID is invalid
* (AccessTokenService)
*/
export const ACCESS_TOKEN_NOT_FOUND = 'access_token/access_token_not_found';
/**
* AccessTokens is expired
* (AccessTokenService)
*/
export const ACCESS_TOKENS_EXPIRED = 'TOKEN_EXPIRED';
/**
* AccessTokens is invalid
* (AccessTokenService)
*/
export const ACCESS_TOKENS_INVALID = 'TOKEN_INVALID';
/**
* AccessTokens is invalid
* (AccessTokenService)
*/
export const ACCESS_TOKENS_INVALID_DATA_ID = 'INVALID_ID';

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,4 +1,4 @@
import { Global, Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MailerModule as NestMailerModule } from '@nestjs-modules/mailer'; import { MailerModule as NestMailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter'; import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { MailerService } from './mailer.service'; import { MailerService } from './mailer.service';
@@ -7,35 +7,16 @@ import {
MAILER_FROM_ADDRESS_UNDEFINED, MAILER_FROM_ADDRESS_UNDEFINED,
MAILER_SMTP_URL_UNDEFINED, MAILER_SMTP_URL_UNDEFINED,
} from 'src/errors'; } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { loadInfraConfiguration } from 'src/infra-config/helper';
@Global()
@Module({ @Module({
imports: [],
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: [ imports: [
NestMailerModule.forRoot({ NestMailerModule.forRoot({
transport: mailerSmtpUrl ?? throwErr(MAILER_SMTP_URL_UNDEFINED), transport:
process.env.MAILER_SMTP_URL ?? throwErr(MAILER_SMTP_URL_UNDEFINED),
defaults: { defaults: {
from: mailerAddressFrom ?? throwErr(MAILER_FROM_ADDRESS_UNDEFINED), from:
process.env.MAILER_ADDRESS_FROM ??
throwErr(MAILER_FROM_ADDRESS_UNDEFINED),
}, },
template: { template: {
dir: __dirname + '/templates', dir: __dirname + '/templates',
@@ -43,6 +24,7 @@ export class MailerModule {
}, },
}), }),
], ],
}; providers: [MailerService],
} exports: [MailerService],
} })
export class MailerModule {}

View File

@@ -25,7 +25,7 @@ export class MailerService {
): string { ): string {
switch (mailDesc.template) { switch (mailDesc.template) {
case 'team-invitation': case 'team-invitation':
return `A user has invited you to join a team workspace in Hoppscotch`; return `${mailDesc.variables.invitee} invited you to join ${mailDesc.variables.invite_team_name} in Hoppscotch`;
case 'user-invitation': case 'user-invitation':
return 'Sign in to Hoppscotch'; return 'Sign in to Hoppscotch';

View File

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

View File

@@ -27,12 +27,6 @@
color: #3869D4; color: #3869D4;
} }
a.nohighlight {
color: inherit !important;
text-decoration: none !important;
cursor: default !important;
}
a img { a img {
border: none; border: none;
} }

View File

@@ -6,24 +6,18 @@ import { VersioningType } from '@nestjs/common';
import * as session from 'express-session'; import * as session from 'express-session';
import { emitGQLSchemaFile } from './gql-schema'; import { emitGQLSchemaFile } from './gql-schema';
import { checkEnvironmentAuthProvider } from './utils'; import { checkEnvironmentAuthProvider } from './utils';
import { ConfigService } from '@nestjs/config';
async function bootstrap() { async function bootstrap() {
console.log(`Running in production: ${process.env.PRODUCTION}`);
console.log(`Port: ${process.env.PORT}`);
checkEnvironmentAuthProvider();
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
console.log(`Running in production: ${configService.get('PRODUCTION')}`);
console.log(`Port: ${configService.get('PORT')}`);
checkEnvironmentAuthProvider(
configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS') ??
configService.get('VITE_ALLOWED_AUTH_PROVIDERS'),
);
app.use( app.use(
session({ session({
secret: configService.get('SESSION_SECRET'), secret: process.env.SESSION_SECRET,
}), }),
); );
@@ -34,18 +28,18 @@ async function bootstrap() {
}), }),
); );
if (configService.get('PRODUCTION') === 'false') { if (process.env.PRODUCTION === 'false') {
console.log('Enabling CORS with development settings'); console.log('Enabling CORS with development settings');
app.enableCors({ app.enableCors({
origin: configService.get('WHITELISTED_ORIGINS').split(','), origin: process.env.WHITELISTED_ORIGINS.split(','),
credentials: true, credentials: true,
}); });
} else { } else {
console.log('Enabling CORS with production settings'); console.log('Enabling CORS with production settings');
app.enableCors({ app.enableCors({
origin: configService.get('WHITELISTED_ORIGINS').split(','), origin: process.env.WHITELISTED_ORIGINS.split(','),
credentials: true, credentials: true,
}); });
} }
@@ -53,13 +47,7 @@ async function bootstrap() {
type: VersioningType.URI, type: VersioningType.URI,
}); });
app.use(cookieParser()); app.use(cookieParser());
await app.listen(configService.get('PORT') || 3170); await app.listen(process.env.PORT || 3170);
// Graceful shutdown
process.on('SIGTERM', async () => {
console.info('SIGTERM signal received');
await app.close();
});
} }
if (!process.env.GENERATE_GQL_SCHEMA) { if (!process.env.GENERATE_GQL_SCHEMA) {

View File

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

View File

@@ -1,58 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PostHog } from 'posthog-node';
import { Cron, CronExpression, SchedulerRegistry } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from 'src/prisma/prisma.service';
import { CronJob } from 'cron';
import { POSTHOG_CLIENT_NOT_INITIALIZED } from 'src/errors';
import { throwErr } from 'src/utils';
@Injectable()
export class PosthogService {
private postHogClient: PostHog;
private POSTHOG_API_KEY = 'phc_9CipPajQC22mSkk2wxe2TXsUA0Ysyupe8dt5KQQELqx';
constructor(
private readonly configService: ConfigService,
private readonly prismaService: PrismaService,
private schedulerRegistry: SchedulerRegistry,
) {}
async onModuleInit() {
if (this.configService.get('INFRA.ALLOW_ANALYTICS_COLLECTION') === 'true') {
console.log('Initializing PostHog');
this.postHogClient = new PostHog(this.POSTHOG_API_KEY, {
host: 'https://eu.posthog.com',
});
// Schedule the cron job only if analytics collection is allowed
this.scheduleCronJob();
}
}
private scheduleCronJob() {
const job = new CronJob(CronExpression.EVERY_WEEK, async () => {
await this.capture();
});
this.schedulerRegistry.addCronJob('captureAnalytics', job);
job.start();
}
async capture() {
if (!this.postHogClient) {
throwErr(POSTHOG_CLIENT_NOT_INITIALIZED);
}
this.postHogClient.capture({
distinctId: this.configService.get('INFRA.ANALYTICS_USER_ID'),
event: 'sh_instance',
properties: {
type: 'COMMUNITY',
total_user_count: await this.prismaService.user.count(),
total_workspace_count: await this.prismaService.team.count(),
version: this.configService.get('npm_package_version'),
},
});
console.log('Sent event to PostHog');
}
}

View File

@@ -48,7 +48,6 @@ const user: AuthUser = {
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false, isAdmin: false,
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: createdOn,
createdOn: createdOn, createdOn: createdOn,
currentGQLSession: {}, currentGQLSession: {},
currentRESTSession: {}, currentRESTSession: {},
@@ -505,24 +504,20 @@ describe('ShortcodeService', () => {
); );
expect(result).toEqual(<ShortcodeWithUserEmail[]>[ expect(result).toEqual(<ShortcodeWithUserEmail[]>[
{ {
id: shortcodesWithUserEmail[0].id, id: shortcodes[0].id,
request: JSON.stringify(shortcodesWithUserEmail[0].request), request: JSON.stringify(shortcodes[0].request),
properties: JSON.stringify( properties: JSON.stringify(shortcodes[0].embedProperties),
shortcodesWithUserEmail[0].embedProperties, createdOn: shortcodes[0].createdOn,
),
createdOn: shortcodesWithUserEmail[0].createdOn,
creator: { creator: {
uid: user.uid, uid: user.uid,
email: user.email, email: user.email,
}, },
}, },
{ {
id: shortcodesWithUserEmail[1].id, id: shortcodes[1].id,
request: JSON.stringify(shortcodesWithUserEmail[1].request), request: JSON.stringify(shortcodes[1].request),
properties: JSON.stringify( properties: JSON.stringify(shortcodes[1].embedProperties),
shortcodesWithUserEmail[1].embedProperties, createdOn: shortcodes[1].createdOn,
),
createdOn: shortcodesWithUserEmail[1].createdOn,
creator: { creator: {
uid: user.uid, uid: user.uid,
email: user.email, email: user.email,

View File

@@ -1,25 +0,0 @@
import { TeamRequest } from '@prisma/client';
// Type of data returned from the query to obtain all search results
export type SearchQueryReturnType = {
id: string;
title: string;
type: 'collection' | 'request';
method?: string;
};
// Type of data returned from the query to obtain all parents
export type ParentTreeQueryReturnType = {
id: string;
parentID: string;
title: string;
};
// Type of data returned from the query to fetch collection details from CLI
export type GetCollectionResponse = {
id: string;
data: string | null;
title: string;
parentID: string | null;
folders: GetCollectionResponse[];
requests: TeamRequest[];
};

View File

@@ -1,54 +0,0 @@
import {
Controller,
Get,
HttpStatus,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { TeamCollectionService } from './team-collection.service';
import * as E from 'fp-ts/Either';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator';
import { TeamMemberRole } from '@prisma/client';
import { RESTTeamMemberGuard } from 'src/team/guards/rest-team-member.guard';
import { throwHTTPErr } from 'src/utils';
import { RESTError } from 'src/types/RESTError';
import { INVALID_PARAMS } from 'src/errors';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'team-collection', version: '1' })
export class TeamCollectionController {
constructor(private readonly teamCollectionService: TeamCollectionService) {}
@Get('search/:teamID')
@RequiresTeamRole(
TeamMemberRole.VIEWER,
TeamMemberRole.EDITOR,
TeamMemberRole.OWNER,
)
@UseGuards(JwtAuthGuard, RESTTeamMemberGuard)
async searchByTitle(
@Query('searchQuery') searchQuery: string,
@Param('teamID') teamID: string,
@Query('take') take: string,
@Query('skip') skip: string,
) {
if (!teamID || !searchQuery) {
return <RESTError>{
message: INVALID_PARAMS,
statusCode: HttpStatus.BAD_REQUEST,
};
}
const res = await this.teamCollectionService.searchByTitle(
searchQuery.trim(),
teamID,
parseInt(take),
parseInt(skip),
);
if (E.isLeft(res)) throwHTTPErr(res.left);
return res.right;
}
}

View File

@@ -6,7 +6,6 @@ import { GqlCollectionTeamMemberGuard } from './guards/gql-collection-team-membe
import { TeamModule } from '../team/team.module'; import { TeamModule } from '../team/team.module';
import { UserModule } from '../user/user.module'; import { UserModule } from '../user/user.module';
import { PubSubModule } from '../pubsub/pubsub.module'; import { PubSubModule } from '../pubsub/pubsub.module';
import { TeamCollectionController } from './team-collection.controller';
@Module({ @Module({
imports: [PrismaModule, TeamModule, UserModule, PubSubModule], imports: [PrismaModule, TeamModule, UserModule, PubSubModule],
@@ -16,6 +15,5 @@ import { TeamCollectionController } from './team-collection.controller';
GqlCollectionTeamMemberGuard, GqlCollectionTeamMemberGuard,
], ],
exports: [TeamCollectionService, GqlCollectionTeamMemberGuard], exports: [TeamCollectionService, GqlCollectionTeamMemberGuard],
controllers: [TeamCollectionController],
}) })
export class TeamCollectionModule {} export class TeamCollectionModule {}

View File

@@ -12,7 +12,6 @@ import {
TEAM_COL_REORDERING_FAILED, TEAM_COL_REORDERING_FAILED,
TEAM_COL_SAME_NEXT_COLL, TEAM_COL_SAME_NEXT_COLL,
TEAM_INVALID_COLL_ID, TEAM_INVALID_COLL_ID,
TEAM_MEMBER_NOT_FOUND,
TEAM_NOT_OWNER, TEAM_NOT_OWNER,
} from 'src/errors'; } from 'src/errors';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
@@ -20,18 +19,15 @@ import { PubSubService } from 'src/pubsub/pubsub.service';
import { AuthUser } from 'src/types/AuthUser'; import { AuthUser } from 'src/types/AuthUser';
import { TeamCollectionService } from './team-collection.service'; import { TeamCollectionService } from './team-collection.service';
import { TeamCollection } from './team-collection.model'; import { TeamCollection } from './team-collection.model';
import { TeamService } from 'src/team/team.service';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>(); const mockPubSub = mockDeep<PubSubService>();
const mockTeamService = mockDeep<TeamService>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
const teamCollectionService = new TeamCollectionService( const teamCollectionService = new TeamCollectionService(
mockPrisma, mockPrisma,
mockPubSub as any, mockPubSub as any,
mockTeamService,
); );
const currentTime = new Date(); const currentTime = new Date();
@@ -43,7 +39,6 @@ const user: AuthUser = {
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false, isAdmin: false,
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime, createdOn: currentTime,
currentGQLSession: {}, currentGQLSession: {},
currentRESTSession: {}, currentRESTSession: {},
@@ -1743,63 +1738,3 @@ describe('updateTeamCollection', () => {
}); });
//ToDo: write test cases for exportCollectionsToJSON //ToDo: write test cases for exportCollectionsToJSON
describe('getCollectionForCLI', () => {
test('should throw TEAM_COLL_NOT_FOUND if collectionID is invalid', async () => {
mockPrisma.teamCollection.findUniqueOrThrow.mockRejectedValueOnce(
'NotFoundError',
);
const result = await teamCollectionService.getCollectionForCLI(
'invalidID',
user.uid,
);
expect(result).toEqualLeft(TEAM_COLL_NOT_FOUND);
});
test('should throw TEAM_MEMBER_NOT_FOUND if user not in same team', async () => {
mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce(
rootTeamCollection,
);
mockTeamService.getTeamMember.mockResolvedValue(null);
const result = await teamCollectionService.getCollectionForCLI(
rootTeamCollection.id,
user.uid,
);
expect(result).toEqualLeft(TEAM_MEMBER_NOT_FOUND);
});
// test('should return the TeamCollection data for CLI', async () => {
// mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce(
// rootTeamCollection,
// );
// mockTeamService.getTeamMember.mockResolvedValue({
// membershipID: 'sdc3sfdv',
// userUid: user.uid,
// role: TeamMemberRole.OWNER,
// });
// const result = await teamCollectionService.getCollectionForCLI(
// rootTeamCollection.id,
// user.uid,
// );
// expect(result).toEqualRight({
// id: rootTeamCollection.id,
// data: JSON.stringify(rootTeamCollection.data),
// title: rootTeamCollection.title,
// parentID: rootTeamCollection.parentID,
// folders: [
// {
// id: childTeamCollection.id,
// data: JSON.stringify(childTeamCollection.data),
// title: childTeamCollection.title,
// parentID: childTeamCollection.parentID,
// folders: [],
// requests: [],
// },
// ],
// requests: [],
// });
// });
});

View File

@@ -1,4 +1,4 @@
import { HttpStatus, Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { TeamCollection } from './team-collection.model'; import { TeamCollection } from './team-collection.model';
import { import {
@@ -14,38 +14,20 @@ import {
TEAM_COL_SAME_NEXT_COLL, TEAM_COL_SAME_NEXT_COLL,
TEAM_COL_REORDERING_FAILED, TEAM_COL_REORDERING_FAILED,
TEAM_COLL_DATA_INVALID, TEAM_COLL_DATA_INVALID,
TEAM_REQ_SEARCH_FAILED,
TEAM_COL_SEARCH_FAILED,
TEAM_REQ_PARENT_TREE_GEN_FAILED,
TEAM_COLL_PARENT_TREE_GEN_FAILED,
TEAM_MEMBER_NOT_FOUND,
} from '../errors'; } from '../errors';
import { PubSubService } from '../pubsub/pubsub.service'; import { PubSubService } from '../pubsub/pubsub.service';
import { escapeSqlLikeString, isValidLength } from 'src/utils'; import { isValidLength } from 'src/utils';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import { import { Prisma, TeamCollection as DBTeamCollection } from '@prisma/client';
Prisma,
TeamCollection as DBTeamCollection,
TeamRequest,
} from '@prisma/client';
import { CollectionFolder } from 'src/types/CollectionFolder'; import { CollectionFolder } from 'src/types/CollectionFolder';
import { stringToJson } from 'src/utils'; import { stringToJson } from 'src/utils';
import { CollectionSearchNode } from 'src/types/CollectionSearchNode';
import {
GetCollectionResponse,
ParentTreeQueryReturnType,
SearchQueryReturnType,
} from './helper';
import { RESTError } from 'src/types/RESTError';
import { TeamService } from 'src/team/team.service';
@Injectable() @Injectable()
export class TeamCollectionService { export class TeamCollectionService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
private readonly teamService: TeamService,
) {} ) {}
TITLE_LENGTH = 3; TITLE_LENGTH = 3;
@@ -1074,376 +1056,4 @@ export class TeamCollectionService {
return E.left(TEAM_COLL_NOT_FOUND); return E.left(TEAM_COLL_NOT_FOUND);
} }
} }
/**
* Search for TeamCollections and TeamRequests by title
*
* @param searchQuery The search query
* @param teamID The Team ID
* @param take Number of items we want returned
* @param skip Number of items we want to skip
* @returns An Either of the search results
*/
async searchByTitle(
searchQuery: string,
teamID: string,
take = 10,
skip = 0,
) {
// Fetch all collections and requests that match the search query
const searchResults: SearchQueryReturnType[] = [];
const matchedCollections = await this.searchCollections(
searchQuery,
teamID,
take,
skip,
);
if (E.isLeft(matchedCollections))
return E.left(<RESTError>{
message: matchedCollections.left,
statusCode: HttpStatus.NOT_FOUND,
});
searchResults.push(...matchedCollections.right);
const matchedRequests = await this.searchRequests(
searchQuery,
teamID,
take,
skip,
);
if (E.isLeft(matchedRequests))
return E.left(<RESTError>{
message: matchedRequests.left,
statusCode: HttpStatus.NOT_FOUND,
});
searchResults.push(...matchedRequests.right);
// Generate the parent tree for searchResults
const searchResultsWithTree: CollectionSearchNode[] = [];
for (let i = 0; i < searchResults.length; i++) {
const fetchedParentTree = await this.fetchParentTree(searchResults[i]);
if (E.isLeft(fetchedParentTree))
return E.left(<RESTError>{
message: fetchedParentTree.left,
statusCode: HttpStatus.NOT_FOUND,
});
searchResultsWithTree.push({
type: searchResults[i].type,
title: searchResults[i].title,
method: searchResults[i].method,
id: searchResults[i].id,
path: !fetchedParentTree
? []
: (fetchedParentTree.right as CollectionSearchNode[]),
});
}
return E.right({ data: searchResultsWithTree });
}
/**
* Search for TeamCollections by title
*
* @param searchQuery The search query
* @param teamID The Team ID
* @param take Number of items we want returned
* @param skip Number of items we want to skip
* @returns An Either of the search results
*/
private async searchCollections(
searchQuery: string,
teamID: string,
take: number,
skip: number,
) {
const query = Prisma.sql`
SELECT
id,title,'collection' AS type
FROM
"TeamCollection"
WHERE
"TeamCollection"."teamID"=${teamID}
AND
title ILIKE ${`%${escapeSqlLikeString(searchQuery)}%`}
ORDER BY
similarity(title, ${searchQuery})
LIMIT ${take}
OFFSET ${skip === 0 ? 0 : (skip - 1) * take};
`;
try {
const res = await this.prisma.$queryRaw<SearchQueryReturnType[]>(query);
return E.right(res);
} catch (error) {
return E.left(TEAM_COL_SEARCH_FAILED);
}
}
/**
* Search for TeamRequests by title
*
* @param searchQuery The search query
* @param teamID The Team ID
* @param take Number of items we want returned
* @param skip Number of items we want to skip
* @returns An Either of the search results
*/
private async searchRequests(
searchQuery: string,
teamID: string,
take: number,
skip: number,
) {
const query = Prisma.sql`
SELECT
id,title,request->>'method' as method,'request' AS type
FROM
"TeamRequest"
WHERE
"TeamRequest"."teamID"=${teamID}
AND
title ILIKE ${`%${escapeSqlLikeString(searchQuery)}%`}
ORDER BY
similarity(title, ${searchQuery})
LIMIT ${take}
OFFSET ${skip === 0 ? 0 : (skip - 1) * take};
`;
try {
const res = await this.prisma.$queryRaw<SearchQueryReturnType[]>(query);
return E.right(res);
} catch (error) {
return E.left(TEAM_REQ_SEARCH_FAILED);
}
}
/**
* Generate the parent tree of a search result
*
* @param searchResult The search result for which we want to generate the parent tree
* @returns The parent tree of the search result
*/
private async fetchParentTree(searchResult: SearchQueryReturnType) {
return searchResult.type === 'collection'
? await this.fetchCollectionParentTree(searchResult.id)
: await this.fetchRequestParentTree(searchResult.id);
}
/**
* Generate the parent tree of a collection
*
* @param id The ID of the collection
* @returns The parent tree of the collection
*/
private async fetchCollectionParentTree(id: string) {
try {
const query = Prisma.sql`
WITH RECURSIVE collection_tree AS (
SELECT tc.id, tc."parentID", tc.title
FROM "TeamCollection" AS tc
JOIN "TeamCollection" AS tr ON tc.id = tr."parentID"
WHERE tr.id = ${id}
UNION ALL
SELECT parent.id, parent."parentID", parent.title
FROM "TeamCollection" AS parent
JOIN collection_tree AS ct ON parent.id = ct."parentID"
)
SELECT * FROM collection_tree;
`;
const res = await this.prisma.$queryRaw<ParentTreeQueryReturnType[]>(
query,
);
const collectionParentTree = this.generateParentTree(res);
return E.right(collectionParentTree);
} catch (error) {
E.left(TEAM_COLL_PARENT_TREE_GEN_FAILED);
}
}
/**
* Generate the parent tree from the collections
*
* @param parentCollections The parent collections
* @returns The parent tree of the parent collections
*/
private generateParentTree(parentCollections: ParentTreeQueryReturnType[]) {
function findChildren(id: string): CollectionSearchNode[] {
const collection = parentCollections.filter((item) => item.id === id)[0];
if (collection.parentID == null) {
return <CollectionSearchNode[]>[
{
id: collection.id,
title: collection.title,
type: 'collection' as const,
path: [],
},
];
}
const res = <CollectionSearchNode[]>[
{
id: collection.id,
title: collection.title,
type: 'collection' as const,
path: findChildren(collection.parentID),
},
];
return res;
}
if (parentCollections.length > 0) {
if (parentCollections[0].parentID == null) {
return <CollectionSearchNode[]>[
{
id: parentCollections[0].id,
title: parentCollections[0].title,
type: 'collection',
path: [],
},
];
}
return <CollectionSearchNode[]>[
{
id: parentCollections[0].id,
title: parentCollections[0].title,
type: 'collection',
path: findChildren(parentCollections[0].parentID),
},
];
}
return <CollectionSearchNode[]>[];
}
/**
* Generate the parent tree of a request
*
* @param id The ID of the request
* @returns The parent tree of the request
*/
private async fetchRequestParentTree(id: string) {
try {
const query = Prisma.sql`
WITH RECURSIVE request_collection_tree AS (
SELECT tc.id, tc."parentID", tc.title
FROM "TeamCollection" AS tc
JOIN "TeamRequest" AS tr ON tc.id = tr."collectionID"
WHERE tr.id = ${id}
UNION ALL
SELECT parent.id, parent."parentID", parent.title
FROM "TeamCollection" AS parent
JOIN request_collection_tree AS ct ON parent.id = ct."parentID"
)
SELECT * FROM request_collection_tree;
`;
const res = await this.prisma.$queryRaw<ParentTreeQueryReturnType[]>(
query,
);
const requestParentTree = this.generateParentTree(res);
return E.right(requestParentTree);
} catch (error) {
return E.left(TEAM_REQ_PARENT_TREE_GEN_FAILED);
}
}
/**
* Get all requests in a collection
*
* @param collectionID The Collection ID
* @returns A list of all requests in the collection
*/
private async getAllRequestsInCollection(collectionID: string) {
const dbTeamRequests = await this.prisma.teamRequest.findMany({
where: {
collectionID: collectionID,
},
orderBy: {
orderIndex: 'asc',
},
});
const teamRequests = dbTeamRequests.map((tr) => {
return <TeamRequest>{
id: tr.id,
collectionID: tr.collectionID,
teamID: tr.teamID,
title: tr.title,
request: JSON.stringify(tr.request),
};
});
return teamRequests;
}
/**
* Get Collection Tree for CLI
*
* @param parentID The parent Collection ID
* @returns Collection tree for CLI
*/
private async getCollectionTreeForCLI(parentID: string | null) {
const childCollections = await this.prisma.teamCollection.findMany({
where: { parentID },
orderBy: { orderIndex: 'asc' },
});
const response: GetCollectionResponse[] = [];
for (const collection of childCollections) {
const folder: GetCollectionResponse = {
id: collection.id,
data: collection.data === null ? null : JSON.stringify(collection.data),
title: collection.title,
parentID: collection.parentID,
folders: await this.getCollectionTreeForCLI(collection.id),
requests: await this.getAllRequestsInCollection(collection.id),
};
response.push(folder);
}
return response;
}
/**
* Get Collection for CLI
*
* @param collectionID The Collection ID
* @param userUid The User UID
* @returns An Either of the Collection details
*/
async getCollectionForCLI(collectionID: string, userUid: string) {
try {
const collection = await this.prisma.teamCollection.findUniqueOrThrow({
where: { id: collectionID },
});
const teamMember = await this.teamService.getTeamMember(
collection.teamID,
userUid,
);
if (!teamMember) return E.left(TEAM_MEMBER_NOT_FOUND);
return E.right(<GetCollectionResponse>{
id: collection.id,
data: collection.data === null ? null : JSON.stringify(collection.data),
title: collection.title,
parentID: collection.parentID,
folders: await this.getCollectionTreeForCLI(collection.id),
requests: await this.getAllRequestsInCollection(collection.id),
});
} catch (error) {
return E.left(TEAM_COLL_NOT_FOUND);
}
}
} }

View File

@@ -6,24 +6,19 @@ import {
JSON_INVALID, JSON_INVALID,
TEAM_ENVIRONMENT_NOT_FOUND, TEAM_ENVIRONMENT_NOT_FOUND,
TEAM_ENVIRONMENT_SHORT_NAME, TEAM_ENVIRONMENT_SHORT_NAME,
TEAM_MEMBER_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { TeamService } from 'src/team/team.service';
import { TeamMemberRole } from 'src/team/team.model';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = { const mockPubSub = {
publish: jest.fn().mockResolvedValue(null), publish: jest.fn().mockResolvedValue(null),
}; };
const mockTeamService = mockDeep<TeamService>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
const teamEnvironmentsService = new TeamEnvironmentsService( const teamEnvironmentsService = new TeamEnvironmentsService(
mockPrisma, mockPrisma,
mockPubSub as any, mockPubSub as any,
mockTeamService,
); );
const teamEnvironment = { const teamEnvironment = {
@@ -385,47 +380,4 @@ describe('TeamEnvironmentsService', () => {
expect(result).toEqual(0); expect(result).toEqual(0);
}); });
}); });
describe('getTeamEnvironmentForCLI', () => {
test('should successfully return a TeamEnvironment with valid ID', async () => {
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
teamEnvironment,
);
mockTeamService.getTeamMember.mockResolvedValue({
membershipID: 'sdc3sfdv',
userUid: '123454',
role: TeamMemberRole.OWNER,
});
const result = await teamEnvironmentsService.getTeamEnvironmentForCLI(
teamEnvironment.id,
'123454',
);
expect(result).toEqualRight(teamEnvironment);
});
test('should throw TEAM_ENVIRONMENT_NOT_FOUND with invalid ID', async () => {
mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce(
'RejectOnNotFound',
);
const result = await teamEnvironmentsService.getTeamEnvironment(
teamEnvironment.id,
);
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
});
test('should throw TEAM_MEMBER_NOT_FOUND if user not in same team', async () => {
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
teamEnvironment,
);
mockTeamService.getTeamMember.mockResolvedValue(null);
const result = await teamEnvironmentsService.getTeamEnvironmentForCLI(
teamEnvironment.id,
'333',
);
expect(result).toEqualLeft(TEAM_MEMBER_NOT_FOUND);
});
});
}); });

View File

@@ -6,17 +6,14 @@ import { TeamEnvironment } from './team-environments.model';
import { import {
TEAM_ENVIRONMENT_NOT_FOUND, TEAM_ENVIRONMENT_NOT_FOUND,
TEAM_ENVIRONMENT_SHORT_NAME, TEAM_ENVIRONMENT_SHORT_NAME,
TEAM_MEMBER_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { isValidLength } from 'src/utils'; import { isValidLength } from 'src/utils';
import { TeamService } from 'src/team/team.service';
@Injectable() @Injectable()
export class TeamEnvironmentsService { export class TeamEnvironmentsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
private readonly teamService: TeamService,
) {} ) {}
TITLE_LENGTH = 3; TITLE_LENGTH = 3;
@@ -245,30 +242,4 @@ export class TeamEnvironmentsService {
}); });
return envCount; return envCount;
} }
/**
* Get details of a TeamEnvironment for CLI.
*
* @param id TeamEnvironment ID
* @param userUid User UID
* @returns Either of a TeamEnvironment or error message
*/
async getTeamEnvironmentForCLI(id: string, userUid: string) {
try {
const teamEnvironment =
await this.prisma.teamEnvironment.findFirstOrThrow({
where: { id },
});
const teamMember = await this.teamService.getTeamMember(
teamEnvironment.teamID,
userUid,
);
if (!teamMember) return E.left(TEAM_MEMBER_NOT_FOUND);
return E.right(teamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
} }

View File

@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MailerModule } from 'src/mailer/mailer.module';
import { PrismaModule } from 'src/prisma/prisma.module'; import { PrismaModule } from 'src/prisma/prisma.module';
import { PubSubModule } from 'src/pubsub/pubsub.module'; import { PubSubModule } from 'src/pubsub/pubsub.module';
import { TeamModule } from 'src/team/team.module'; import { TeamModule } from 'src/team/team.module';
@@ -11,7 +12,7 @@ import { TeamInviteeGuard } from './team-invitee.guard';
import { TeamTeamInviteExtResolver } from './team-teaminvite-ext.resolver'; import { TeamTeamInviteExtResolver } from './team-teaminvite-ext.resolver';
@Module({ @Module({
imports: [PrismaModule, TeamModule, PubSubModule, UserModule], imports: [PrismaModule, TeamModule, PubSubModule, UserModule, MailerModule],
providers: [ providers: [
TeamInvitationService, TeamInvitationService,
TeamInvitationResolver, TeamInvitationResolver,

View File

@@ -20,7 +20,6 @@ import { UserService } from 'src/user/user.service';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { validateEmail } from '../utils'; import { validateEmail } from '../utils';
import { AuthUser } from 'src/types/AuthUser'; import { AuthUser } from 'src/types/AuthUser';
import { ConfigService } from '@nestjs/config';
@Injectable() @Injectable()
export class TeamInvitationService { export class TeamInvitationService {
@@ -29,8 +28,8 @@ export class TeamInvitationService {
private readonly userService: UserService, private readonly userService: UserService,
private readonly teamService: TeamService, private readonly teamService: TeamService,
private readonly mailerService: MailerService, private readonly mailerService: MailerService,
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
private readonly configService: ConfigService,
) {} ) {}
/** /**
@@ -151,9 +150,7 @@ export class TeamInvitationService {
template: 'team-invitation', template: 'team-invitation',
variables: { variables: {
invitee: creator.displayName ?? 'A Hoppscotch User', invitee: creator.displayName ?? 'A Hoppscotch User',
action_url: `${this.configService.get('VITE_BASE_URL')}/join-team?id=${ action_url: `${process.env.VITE_BASE_URL}/join-team?id=${dbInvitation.id}`,
dbInvitation.id
}`,
invite_team_name: team.name, invite_team_name: team.name,
}, },
}); });

View File

@@ -1,47 +0,0 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { TeamService } from '../../team/team.service';
import { TeamMemberRole } from '../../team/team.model';
import {
BUG_TEAM_NO_REQUIRE_TEAM_ROLE,
BUG_AUTH_NO_USER_CTX,
BUG_TEAM_NO_TEAM_ID,
TEAM_MEMBER_NOT_FOUND,
TEAM_NOT_REQUIRED_ROLE,
} from 'src/errors';
import { throwHTTPErr } from 'src/utils';
@Injectable()
export class RESTTeamMemberGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly teamService: TeamService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requireRoles = this.reflector.get<TeamMemberRole[]>(
'requiresTeamRole',
context.getHandler(),
);
if (!requireRoles)
throwHTTPErr({ message: BUG_TEAM_NO_REQUIRE_TEAM_ROLE, statusCode: 400 });
const request = context.switchToHttp().getRequest();
const { user } = request;
if (user == undefined)
throwHTTPErr({ message: BUG_AUTH_NO_USER_CTX, statusCode: 400 });
const teamID = request.params.teamID;
if (!teamID)
throwHTTPErr({ message: BUG_TEAM_NO_TEAM_ID, statusCode: 400 });
const teamMember = await this.teamService.getTeamMember(teamID, user.uid);
if (!teamMember)
throwHTTPErr({ message: TEAM_MEMBER_NOT_FOUND, statusCode: 404 });
if (requireRoles.includes(teamMember.role)) return true;
throwHTTPErr({ message: TEAM_NOT_REQUIRED_ROLE, statusCode: 403 });
}
}

View File

@@ -1,7 +0,0 @@
export type AccessToken = {
id: string;
label: string;
createdOn: Date;
lastUsedOn: Date;
expiresOn: null | Date;
};

View File

@@ -1,10 +1,10 @@
import { HttpStatus } from '@nestjs/common'; import { HttpStatus } from '@nestjs/common';
/** /**
** Custom interface to handle errors for REST modules such as Auth, Admin modules ** Custom interface to handle errors specific to Auth module
** Since its REST we need to return the HTTP status code along with the error message ** Since its REST we need to return the HTTP status code along with the error message
*/ */
export type RESTError = { export type AuthError = {
message: string | Record<string, string>; message: string;
statusCode: HttpStatus; statusCode: HttpStatus;
}; };

View File

@@ -1,17 +0,0 @@
// Response type of results from the search query
export type CollectionSearchNode = {
/** Encodes the hierarchy of where the node is **/
path: CollectionSearchNode[];
} & (
| {
type: 'request';
title: string;
method: string;
id: string;
}
| {
type: 'collection';
title: string;
id: string;
}
);

View File

@@ -1,26 +0,0 @@
export enum InfraConfigEnum {
MAILER_SMTP_URL = 'MAILER_SMTP_URL',
MAILER_ADDRESS_FROM = 'MAILER_ADDRESS_FROM',
GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID',
GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET',
GOOGLE_CALLBACK_URL = 'GOOGLE_CALLBACK_URL',
GOOGLE_SCOPE = 'GOOGLE_SCOPE',
GITHUB_CLIENT_ID = 'GITHUB_CLIENT_ID',
GITHUB_CLIENT_SECRET = 'GITHUB_CLIENT_SECRET',
GITHUB_CALLBACK_URL = 'GITHUB_CALLBACK_URL',
GITHUB_SCOPE = 'GITHUB_SCOPE',
MICROSOFT_CLIENT_ID = 'MICROSOFT_CLIENT_ID',
MICROSOFT_CLIENT_SECRET = 'MICROSOFT_CLIENT_SECRET',
MICROSOFT_CALLBACK_URL = 'MICROSOFT_CALLBACK_URL',
MICROSOFT_SCOPE = 'MICROSOFT_SCOPE',
MICROSOFT_TENANT = 'MICROSOFT_TENANT',
VITE_ALLOWED_AUTH_PROVIDERS = 'VITE_ALLOWED_AUTH_PROVIDERS',
ALLOW_ANALYTICS_COLLECTION = 'ALLOW_ANALYTICS_COLLECTION',
ANALYTICS_USER_ID = 'ANALYTICS_USER_ID',
IS_FIRST_TIME_INFRA_SETUP = 'IS_FIRST_TIME_INFRA_SETUP',
}

View File

@@ -17,21 +17,3 @@ export class PaginationArgs {
}) })
take: number; take: number;
} }
@ArgsType()
@InputType()
export class OffsetPaginationArgs {
@Field({
nullable: true,
defaultValue: 0,
description: 'Number of items to skip',
})
skip: number;
@Field({
nullable: true,
defaultValue: 10,
description: 'Number of items to fetch',
})
take: number;
}

View File

@@ -38,7 +38,6 @@ const user: AuthUser = {
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false, isAdmin: false,
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime, createdOn: currentTime,
currentGQLSession: {}, currentGQLSession: {},
currentRESTSession: {}, currentRESTSession: {},

View File

@@ -41,7 +41,6 @@ const user: AuthUser = {
photoURL: 'https://example.com/photo.png', photoURL: 'https://example.com/photo.png',
isAdmin: false, isAdmin: false,
refreshToken: null, refreshToken: null,
lastLoggedOn: new Date(),
createdOn: new Date(), createdOn: new Date(),
currentGQLSession: null, currentGQLSession: null,
currentRESTSession: null, currentRESTSession: null,

View File

@@ -27,7 +27,6 @@ const user: AuthUser = {
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
currentGQLSession: {}, currentGQLSession: {},
currentRESTSession: {}, currentRESTSession: {},
lastLoggedOn: currentTime,
createdOn: currentTime, createdOn: currentTime,
}; };

View File

@@ -30,12 +30,6 @@ export class User {
}) })
isAdmin: boolean; isAdmin: boolean;
@Field({
nullable: true,
description: 'Date when the user last logged in',
})
lastLoggedOn: Date;
@Field({ @Field({
description: 'Date when the user account was created', description: 'Date when the user account was created',
}) })
@@ -62,22 +56,3 @@ export enum SessionType {
registerEnumType(SessionType, { registerEnumType(SessionType, {
name: 'SessionType', name: 'SessionType',
}); });
@ObjectType()
export class UserDeletionResult {
@Field(() => ID, {
description: 'UID of the user',
})
userUID: string;
@Field(() => Boolean, {
description: 'Flag to determine if user deletion was successful or not',
})
isDeleted: Boolean;
@Field({
nullable: true,
description: 'Error message if user deletion was not successful',
})
errorMessage: String;
}

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