Compare commits

...

66 Commits

Author SHA1 Message Date
Mir Arif Hasan
a4fdc05b5f feat: .env.example updated 2023-08-08 14:10:50 +05:30
Mir Arif Hasan
4a0a5e6a04 feat: env variable changed 2023-08-08 14:10:50 +05:30
Nivedin
085fbb2a9b feat: tippy menu for history and tab (#3220)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-08-08 13:23:11 +05:30
Andrew Bastin
05f2d8817b chore: merge hoppscotch/main into release/2023.8.0 2023-08-08 12:18:01 +05:30
Joel Jacob Stephen
81fbb22c51 feat: introducing a new smart input hoppscotch ui component (#3089)
Co-authored-by: Anwarul Islam <anwaarulislaam@gmail.com>
2023-08-05 23:45:02 +05:30
Liyas Thomas
01cf59c663 fix: disable line wrapping in EnvInput component (#3230) 2023-08-05 23:43:02 +05:30
Liyas Thomas
5c8ebaff3e refactor: fonts are now bundled with packages (#3227) 2023-08-05 23:42:31 +05:30
Andrew Bastin
0e70c28324 feat: dynamically select which auth providers for your instance of hoppscotch (be implementation) 2023-08-03 20:12:54 +05:30
Ankit Sridhar
88f6a4ae26 [feat] : Allow admins to revoke a team invite (HBE-230) (#3162)
feat: added functionality for admin to revoke team invite
2023-08-03 14:08:32 +05:30
Anwarul Islam
610538ca02 chore: type and ux improvement for SmartTree (#3126) 2023-08-02 20:54:02 +05:30
Nivedin
8970ff5c68 feat: context menu (#3180)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-08-02 20:52:16 +05:30
Liyas Thomas
d1a564d5b8 fix: elastic overscroll on safari (#3221) 2023-08-02 20:47:54 +05:30
Anwarul Islam
8bb1d19c07 fix: firefox browser scrollbar issue (#3201) 2023-08-01 13:20:17 +05:30
Liyas Thomas
c1efa381f0 feat: svg badge asset (#3196) 2023-07-25 20:45:22 +05:30
Andrew Bastin
29171d1b6f fix: generate-ui failing to build 2023-07-18 22:27:37 +05:30
Andrew Bastin
e869d49e16 chore: run tests on and against release branches 2023-07-18 21:46:38 +05:30
Andrew Bastin
6496bea846 chore: bump version to 2023.4.8 2023-07-18 21:46:36 +05:30
NicklasWallgren
39842559b5 fix: reduce the memory consumption during build to prevent OOM (#3148)
Co-authored-by: Nicklas Wallgren <nicklas.wallgren@folksam.se>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-07-18 00:08:06 +05:30
Anwarul Islam
51efb35aa6 fix: keybinding modifier issue (#3163) 2023-07-17 23:56:08 +05:30
NicklasWallgren
9402bb9285 fix: add healthcheck for db and remove unwanted volumes (#3150) 2023-07-17 21:22:56 +05:30
Liyas Thomas
5a516f7242 docs: fixed shortcut keys for spotlight and shortcuts menu (#3192) 2023-07-17 19:27:49 +05:30
Liyas Thomas
3b217d78e7 fix: deps mismatch for vite-plugin-pages-sitemap (#3191) 2023-07-17 19:26:43 +05:30
Liyas Thomas
8e153b38dc redesigned search button (#3187) 2023-07-17 14:40:14 +05:30
Akash K
6f38bfb148 chore: update generateSitemap usage (#3182) 2023-07-17 14:39:32 +05:30
Balu Babu
82b6e08d68 fix: fixed issue in team-environment test cases (#3189) 2023-07-17 12:33:11 +05:30
Liyas Thomas
31fd6567b7 fix: text overflow on spotlight search results (#3181) 2023-07-17 12:32:45 +05:30
Anwarul Islam
25177bd635 fix: update vite-plugin-dts version which fixes build issue on docker/alpine (#3179) 2023-07-17 12:32:25 +05:30
5idereal
6928eb7992 feat(lang): update tw translation (#3170) 2023-07-14 11:36:08 +05:30
Andrew Bastin
8300f9a0a2 chore: merge release/2023.4.8 into release/2023.8.0 2023-07-13 12:10:14 +05:30
Mir Arif Hasan
525ba77739 refactor: team invitation module in pseudo fp-ts (#3175) 2023-07-13 11:58:03 +05:30
Balu Babu
6bc748a267 refactor: introduce team-environments into self-host refactored to pseudo-fp format (#3177) 2023-07-13 11:52:19 +05:30
Andrew Bastin
5230d2d3b8 feat: revamped spotlight (#3171)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-07-11 23:02:33 +05:30
Nivedin
c3531c9d8b feat: auto-complete recent history entries in URL bar (#3141)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-07-11 20:03:42 +05:30
Andrew Bastin
b29c04c28d fix: email not being checked case insensitive on team invitation acceptance (#3174) 2023-07-11 20:03:08 +05:30
Liyas Thomas
b2af353941 chore: new filled star icon to toggle favorite history entry (#3164) 2023-07-06 13:30:38 +05:30
Andrew Bastin
9dbce74f5e chore: bump version to 2023.4.7 2023-06-27 15:43:03 +05:30
Liyas Thomas
db1cf5cc08 fix: explicitly added background color 2023-06-27 15:43:03 +05:30
Liyas Thomas
09360abf81 fix: text overflow on details summary label (#3160)
Co-authored-by: Nivedin <nivedinp@gmail.com>
2023-06-27 15:42:58 +05:30
Andrew Bastin
355bd62b8d feat: introduce more events into the analytics pipeline (#3156) 2023-06-27 15:37:25 +05:30
James Butler
5650de1183 fix: self-host unable to use Azure oauth (#3138) 2023-06-27 15:37:25 +05:30
Akash K
2ee8614b93 fix: use --location param for url when parsing curl (#3152) 2023-06-27 15:37:25 +05:30
Ankit Sridhar
5632334c9a fix: remove existing team invitation for an invitee when adding invitee to team by admin (HBE-229) (#3157) 2023-06-27 15:37:25 +05:30
Anwarul Islam
780dd8a713 fix: graphql authorization headers (#3136) 2023-06-27 15:37:25 +05:30
Nivedin
7db3c6d290 fix: unified bg color in collection tree (#3155) 2023-06-27 15:37:25 +05:30
Omer Baflah
c765270dfe fix: correct typos (#3153) 2023-06-27 15:37:25 +05:30
Webysther Sperandio
03f667c21d feat: custom location on admin redirect to base (#3103) 2023-06-27 15:37:25 +05:30
Balázs Úr
f79f3078dc chore(i18n): updated hungarian translation (#3151) 2023-06-27 15:37:25 +05:30
Nivedin
6e29a2f6d4 fix: shortcode resolution screen is stuck on invalid shortcodes (#3142)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-06-27 15:37:25 +05:30
Balu Babu
6304fd50c3 fix: fixed issue with team-invitations and new user accounts (#3137) 2023-06-27 15:37:25 +05:30
Andrew Bastin
2ec29c47ad chore: merge release/2023.4.7 into main 2023-06-27 14:17:26 +05:30
Andrew Bastin
399a238bf4 chore: bump version to 2023.4.7 2023-06-27 14:15:12 +05:30
Liyas Thomas
b20ab72298 fix: explicitly added background color 2023-06-26 19:57:43 +05:30
Liyas Thomas
f723e6496a fix: text overflow on details summary label (#3160)
Co-authored-by: Nivedin <nivedinp@gmail.com>
2023-06-26 18:30:25 +05:30
Andrew Bastin
8c0aff8863 feat: introduce more events into the analytics pipeline (#3156) 2023-06-24 10:18:35 +05:30
James Butler
64c5077506 fix: self-host unable to use Azure oauth (#3138) 2023-06-22 23:43:05 +05:30
Akash K
2afc87847d fix: use --location param for url when parsing curl (#3152) 2023-06-22 23:40:09 +05:30
Ankit Sridhar
878ec833ce fix: remove existing team invitation for an invitee when adding invitee to team by admin (HBE-229) (#3157) 2023-06-22 23:38:02 +05:30
Anwarul Islam
039de8015f fix: graphql authorization headers (#3136) 2023-06-22 23:32:23 +05:30
Nivedin
f67b366b90 fix: unified bg color in collection tree (#3155) 2023-06-22 00:38:28 +05:30
Andrew Bastin
6f35574d68 refactor: move hoppscotch-common tests to vitest (#3154) 2023-06-22 00:36:25 +05:30
Anwarul Islam
fc3e3aeaec feat: placeholder component in hoppscotch-ui (#3123)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-06-21 00:09:16 +05:30
Andrew Bastin
e2b668bee2 chore(ci): add manual workflow dispatch for hoppscotch-ui deploy script 2023-06-19 12:33:52 +05:30
Andrew Bastin
f112c46bb4 chore(ci): re-introduce hoppscotch-ui deploy script 2023-06-19 11:51:14 +05:30
Joel Jacob Stephen
331d482b22 feat: introducing i18n support to admin dashboard (#3051) 2023-06-16 09:47:00 +05:30
Andrew Bastin
b07243f131 chore: merge main@2023.4.6 into release/2023.8.0 2023-06-16 09:45:05 +05:30
Andrew Bastin
81a7e23a12 feat: introduce dioc into hoppscotch-common 2023-06-07 15:20:49 +05:30
274 changed files with 11352 additions and 3915 deletions

1
.dockerignore Normal file
View File

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

View File

@@ -13,6 +13,7 @@ SESSION_SECRET='add some secret here'
# Hoppscotch App Domain Config
REDIRECT_URL="http://localhost:3000"
WHITELISTED_ORIGINS = "http://localhost:3170,http://localhost:3000,http://localhost:3100"
VITE_ALLOWED_AUTH_PROVIDERS = GOOGLE,GITHUB,MICROSOFT,EMAIL
# Google Auth Config
GOOGLE_CLIENT_ID="************************************************"
@@ -31,6 +32,7 @@ MICROSOFT_CLIENT_ID="************************************************"
MICROSOFT_CLIENT_SECRET="************************************************"
MICROSOFT_CALLBACK_URL="http://localhost:3170/v1/auth/microsoft/callback"
MICROSOFT_SCOPE="user.read"
MICROSOFT_TENANT="common"
# Mailer config
MAILER_SMTP_URL="smtps://user@domain.com:pass@smtp.domain.com"

View File

@@ -2,9 +2,9 @@ name: Node.js CI
on:
push:
branches: [main, staging]
branches: [main, staging, "release/**"]
pull_request:
branches: [main, staging]
branches: [main, staging, "release/**"]
jobs:
test:

View File

@@ -19,10 +19,12 @@ services:
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
- PORT=3000
volumes:
- ./packages/hoppscotch-backend/:/usr/src/app
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
# - ./packages/hoppscotch-backend/:/usr/src/app
- /usr/src/app/node_modules/
depends_on:
- hoppscotch-db
hoppscotch-db:
condition: service_healthy
ports:
- "3170:3000"
@@ -60,12 +62,20 @@ services:
# you are using an external postgres instance
# This will be exposed at port 5432
hoppscotch-db:
image: postgres
image: postgres:15
ports:
- "5432:5432"
user: postgres
environment:
# The default user defined by the docker image
POSTGRES_USER: postgres
# NOTE: Please UPDATE THIS PASSWORD!
POSTGRES_PASSWORD: testpass
POSTGRES_DB: hoppscotch
healthcheck:
test: ["CMD-SHELL", "sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'"]
interval: 5s
timeout: 5s
retries: 10

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,6 +1,6 @@
{
"name": "hoppscotch-backend",
"version": "2023.4.6",
"version": "2023.4.8",
"description": "",
"author": "",
"private": true,

View File

@@ -411,6 +411,23 @@ export class AdminResolver {
return deletedTeam.right;
}
@Mutation(() => Boolean, {
description: 'Revoke a team Invite by Invite ID',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async revokeTeamInviteByAdmin(
@Args({
name: 'inviteID',
description: 'Team Invite ID',
type: () => ID,
})
inviteID: string,
): Promise<boolean> {
const invite = await this.adminService.revokeTeamInviteByID(inviteID);
if (E.isLeft(invite)) throwErr(invite.left);
return true;
}
/* Subscriptions */
@Subscription(() => InvitedUser, {

View File

@@ -11,6 +11,7 @@ import {
INVALID_EMAIL,
ONLY_ONE_ADMIN_ACCOUNT,
TEAM_INVITE_ALREADY_MEMBER,
TEAM_INVITE_NO_INVITE_FOUND,
USER_ALREADY_INVITED,
USER_IS_ADMIN,
USER_NOT_FOUND,
@@ -181,7 +182,7 @@ export class AdminService {
* @returns an array team invitations
*/
async pendingInvitationCountInTeam(teamID: string) {
const invitations = await this.teamInvitationService.getAllTeamInvitations(
const invitations = await this.teamInvitationService.getTeamInvitations(
teamID,
);
@@ -236,11 +237,11 @@ export class AdminService {
const user = await this.userService.findUserByEmail(userEmail);
if (O.isNone(user)) return E.left(USER_NOT_FOUND);
const isUserAlreadyMember = await this.teamService.getTeamMemberTE(
const teamMember = await this.teamService.getTeamMemberTE(
teamID,
user.value.uid,
)();
if (E.left(isUserAlreadyMember)) {
if (E.isLeft(teamMember)) {
const addedUser = await this.teamService.addMemberToTeamWithEmail(
teamID,
userEmail,
@@ -248,6 +249,18 @@ export class AdminService {
);
if (E.isLeft(addedUser)) return E.left(addedUser.left);
const userInvitation =
await this.teamInvitationService.getTeamInviteByEmailAndTeamID(
userEmail,
teamID,
);
if (E.isRight(userInvitation)) {
await this.teamInvitationService.revokeInvitation(
userInvitation.right.id,
);
}
return E.right(addedUser.right);
}
@@ -404,4 +417,19 @@ export class AdminService {
if (E.isLeft(team)) return E.left(team.left);
return E.right(team.right);
}
/**
* Revoke a team invite by ID
* @param inviteID Team Invite ID
* @returns an Either of boolean or error
*/
async revokeTeamInviteByID(inviteID: string) {
const teamInvite = await this.teamInvitationService.revokeInvitation(
inviteID,
);
if (E.isLeft(teamInvite)) return E.left(teamInvite.left);
return E.right(teamInvite.right);
}
}

View File

@@ -2,9 +2,9 @@ import {
Body,
Controller,
Get,
InternalServerErrorException,
Post,
Query,
Req,
Request,
Res,
UseGuards,
@@ -19,12 +19,18 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { GqlUser } from 'src/decorators/gql-user.decorator';
import { AuthUser } from 'src/types/AuthUser';
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
import { authCookieHandler, throwHTTPErr } from './helper';
import {
AuthProvider,
authCookieHandler,
authProviderCheck,
throwHTTPErr,
} from './helper';
import { GoogleSSOGuard } from './guards/google-sso.guard';
import { GithubSSOGuard } from './guards/github-sso.guard';
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import { SkipThrottle } from '@nestjs/throttler';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'auth', version: '1' })
@@ -39,6 +45,9 @@ export class AuthController {
@Body() authData: SignInMagicDto,
@Query('origin') origin: string,
) {
if (!authProviderCheck(AuthProvider.EMAIL))
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
const deviceIdToken = await this.authService.signInMagicLink(
authData.email,
origin,

View File

@@ -11,6 +11,7 @@ import { RTJwtStrategy } from './strategies/rt-jwt.strategy';
import { GoogleStrategy } from './strategies/google.strategy';
import { GithubStrategy } from './strategies/github.strategy';
import { MicrosoftStrategy } from './strategies/microsoft.strategy';
import { AuthProvider, authProviderCheck } from './helper';
@Module({
imports: [
@@ -26,9 +27,9 @@ import { MicrosoftStrategy } from './strategies/microsoft.strategy';
AuthService,
JwtStrategy,
RTJwtStrategy,
GoogleStrategy,
GithubStrategy,
MicrosoftStrategy,
...(authProviderCheck(AuthProvider.GOOGLE) ? [GoogleStrategy] : []),
...(authProviderCheck(AuthProvider.GITHUB) ? [GithubStrategy] : []),
...(authProviderCheck(AuthProvider.MICROSOFT) ? [MicrosoftStrategy] : []),
],
controllers: [AuthController],
})

View File

@@ -228,7 +228,7 @@ export class AuthService {
url = process.env.VITE_BASE_URL;
}
await this.mailerService.sendAuthEmail(email, {
await this.mailerService.sendEmail(email, {
template: 'code-your-own',
variables: {
inviteeEmail: email,

View File

@@ -1,8 +1,20 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
@Injectable()
export class GithubSSOGuard extends AuthGuard('github') {
export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
if (!authProviderCheck(AuthProvider.GITHUB))
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
return super.canActivate(context);
}
getAuthenticateOptions(context: ExecutionContext) {
const req = context.switchToHttp().getRequest();

View File

@@ -1,8 +1,20 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
@Injectable()
export class GoogleSSOGuard extends AuthGuard('google') {
export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
if (!authProviderCheck(AuthProvider.GOOGLE))
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
return super.canActivate(context);
}
getAuthenticateOptions(context: ExecutionContext) {
const req = context.switchToHttp().getRequest();

View File

@@ -1,8 +1,26 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
@Injectable()
export class MicrosoftSSOGuard extends AuthGuard('microsoft') {
export class MicrosoftSSOGuard
extends AuthGuard('microsoft')
implements CanActivate
{
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
if (!authProviderCheck(AuthProvider.MICROSOFT))
throwHTTPErr({
message: AUTH_PROVIDER_NOT_SPECIFIED,
statusCode: 404,
});
return super.canActivate(context);
}
getAuthenticateOptions(context: ExecutionContext) {
const req = context.switchToHttp().getRequest();

View File

@@ -1,10 +1,11 @@
import { ForbiddenException, HttpException, HttpStatus } from '@nestjs/common';
import { HttpException, HttpStatus } from '@nestjs/common';
import { DateTime } from 'luxon';
import { AuthError } from 'src/types/AuthError';
import { AuthTokens } from 'src/types/AuthTokens';
import { Response } from 'express';
import * as cookie from 'cookie';
import { COOKIES_NOT_FOUND } from 'src/errors';
import { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors';
import { throwErr } from 'src/utils';
enum AuthTokenType {
ACCESS_TOKEN = 'access_token',
@@ -16,6 +17,13 @@ export enum Origin {
APP = 'app',
}
export enum AuthProvider {
GOOGLE = 'GOOGLE',
GITHUB = 'GITHUB',
MICROSOFT = 'MICROSOFT',
EMAIL = 'EMAIL',
}
/**
* This function allows throw to be used as an expression
* @param errMessage Message present in the error message
@@ -97,3 +105,25 @@ export const subscriptionContextCookieParser = (rawCookies: string) => {
refresh_token: cookies[AuthTokenType.REFRESH_TOKEN],
};
};
/**
* Check to see if given auth provider is present in the VITE_ALLOWED_AUTH_PROVIDERS env variable
*
* @param provider Provider we want to check the presence of
* @returns Boolean if provider specified is present or not
*/
export function authProviderCheck(provider: string) {
if (!provider) {
throwErr(AUTH_PROVIDER_NOT_SPECIFIED);
}
const envVariables = process.env.VITE_ALLOWED_AUTH_PROVIDERS
? process.env.VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
provider.trim().toUpperCase(),
)
: [];
if (!envVariables.includes(provider.toUpperCase())) return false;
return true;
}

View File

@@ -17,7 +17,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy) {
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
callbackURL: process.env.MICROSOFT_CALLBACK_URL,
scope: [process.env.MICROSOFT_SCOPE],
passReqToCallback: true,
tenant: process.env.MICROSOFT_TENANT,
store: true,
});
}

View File

@@ -22,6 +22,30 @@ export const AUTH_FAIL = 'auth/fail';
*/
export const JSON_INVALID = 'json_invalid';
/**
* Auth Provider not specified
* (Auth)
*/
export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified';
/**
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file
*/
export const ENV_NOT_FOUND_KEY_AUTH_PROVIDERS =
'"VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file';
/**
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is empty in .env file
*/
export const ENV_EMPTY_AUTH_PROVIDERS =
'"VITE_ALLOWED_AUTH_PROVIDERS" is empty in .env file';
/**
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" contains unsupported provider in .env file
*/
export const ENV_NOT_SUPPORT_AUTH_PROVIDERS =
'"VITE_ALLOWED_AUTH_PROVIDERS" contains an unsupported auth provider in .env file';
/**
* Tried to delete a user data document from fb firestore but failed.
* (FirebaseService)
@@ -312,6 +336,13 @@ export const SHORTCODE_ALREADY_EXISTS = 'shortcode/already_exists' as const;
*/
export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const;
/**
* Invalid TEAM ENVIRONMENT name
* (TeamEnvironmentsService)
*/
export const TEAM_ENVIRONMENT_SHORT_NAME =
'team_environment/short_name' as const;
/**
* The user is not a member of the team of the given environment
* (GqlTeamEnvTeamGuard)

View File

@@ -5,7 +5,6 @@ import {
UserMagicLinkMailDescription,
} from './MailDescriptions';
import { throwErr } from 'src/utils';
import * as TE from 'fp-ts/TaskEither';
import { EMAIL_FAILED } from 'src/errors';
import { MailerService as NestMailerService } from '@nestjs-modules/mailer';
@@ -35,33 +34,14 @@ export class MailerService {
/**
* Sends an email to the given email address given a mail description
* @param to The email address to be sent to (NOTE: this is not validated)
* @param to Receiver's email id
* @param mailDesc Definition of what email to be sent
* @returns Response if email was send successfully or not
*/
sendMail(
async sendEmail(
to: string,
mailDesc: MailDescription | UserMagicLinkMailDescription,
) {
return TE.tryCatch(
async () => {
await this.nestMailerService.sendMail({
to,
template: mailDesc.template,
subject: this.resolveSubjectForMailDesc(mailDesc),
context: mailDesc.variables,
});
},
() => EMAIL_FAILED,
);
}
/**
*
* @param to Receiver's email id
* @param mailDesc Details of email to be sent for Magic-Link auth
* @returns Response if email was send successfully or not
*/
async sendAuthEmail(to: string, mailDesc: UserMagicLinkMailDescription) {
try {
await this.nestMailerService.sendMail({
to,

View File

@@ -5,11 +5,14 @@ import * as cookieParser from 'cookie-parser';
import { VersioningType } from '@nestjs/common';
import * as session from 'express-session';
import { emitGQLSchemaFile } from './gql-schema';
import { checkEnvironmentAuthProvider } from './utils';
async function bootstrap() {
console.log(`Running in production: ${process.env.PRODUCTION}`);
console.log(`Port: ${process.env.PORT}`);
checkEnvironmentAuthProvider();
const app = await NestFactory.create(AppModule);
app.use(

View File

@@ -1,15 +1,5 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import * as TE from 'fp-ts/TaskEither';
import * as O from 'fp-ts/Option';
import * as S from 'fp-ts/string';
import { pipe } from 'fp-ts/function';
import {
getAnnotatedRequiredRoles,
getGqlArg,
getUserFromGQLContext,
throwErr,
} from 'src/utils';
import { TeamEnvironmentsService } from './team-environments.service';
import {
BUG_AUTH_NO_USER_CTX,
@@ -19,6 +9,10 @@ import {
TEAM_ENVIRONMENT_NOT_FOUND,
} from 'src/errors';
import { TeamService } from 'src/team/team.service';
import { GqlExecutionContext } from '@nestjs/graphql';
import * as E from 'fp-ts/Either';
import { TeamMemberRole } from '@prisma/client';
import { throwErr } from 'src/utils';
/**
* A guard which checks whether the caller of a GQL Operation
@@ -33,50 +27,31 @@ export class GqlTeamEnvTeamGuard implements CanActivate {
private readonly teamService: TeamService,
) {}
canActivate(context: ExecutionContext): Promise<boolean> {
return pipe(
TE.Do,
async canActivate(context: ExecutionContext): Promise<boolean> {
const requireRoles = this.reflector.get<TeamMemberRole[]>(
'requiresTeamRole',
context.getHandler(),
);
if (!requireRoles) throw new Error(BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES);
TE.bindW('requiredRoles', () =>
pipe(
getAnnotatedRequiredRoles(this.reflector, context),
TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES),
),
),
const gqlExecCtx = GqlExecutionContext.create(context);
TE.bindW('user', () =>
pipe(
getUserFromGQLContext(context),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
const { user } = gqlExecCtx.getContext().req;
if (user == undefined) throw new Error(BUG_AUTH_NO_USER_CTX);
TE.bindW('envID', () =>
pipe(
getGqlArg('id', context),
O.fromPredicate(S.isString),
TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_ENV_ID),
),
),
const { id } = gqlExecCtx.getArgs<{ id: string }>();
if (!id) throwErr(BUG_TEAM_ENV_GUARD_NO_ENV_ID);
TE.bindW('membership', ({ envID, user }) =>
pipe(
this.teamEnvironmentService.getTeamEnvironment(envID),
TE.fromTaskOption(() => TEAM_ENVIRONMENT_NOT_FOUND),
TE.chainW((env) =>
pipe(
this.teamService.getTeamMemberTE(env.teamID, user.uid),
TE.mapLeft(() => TEAM_ENVIRONMENT_NOT_TEAM_MEMBER),
),
),
),
),
const teamEnvironment =
await this.teamEnvironmentService.getTeamEnvironment(id);
if (E.isLeft(teamEnvironment)) throwErr(TEAM_ENVIRONMENT_NOT_FOUND);
TE.map(({ membership, requiredRoles }) =>
requiredRoles.includes(membership.role),
),
const member = await this.teamService.getTeamMember(
teamEnvironment.right.teamID,
user.uid,
);
if (!member) throwErr(TEAM_ENVIRONMENT_NOT_TEAM_MEMBER);
TE.getOrElse(throwErr),
)();
return requireRoles.includes(member.role);
}
}

View File

@@ -0,0 +1,41 @@
import { ArgsType, Field, ID } from '@nestjs/graphql';
@ArgsType()
export class CreateTeamEnvironmentArgs {
@Field({
name: 'name',
description: 'Name of the Team Environment',
})
name: string;
@Field(() => ID, {
name: 'teamID',
description: 'ID of the Team',
})
teamID: string;
@Field({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string;
}
@ArgsType()
export class UpdateTeamEnvironmentArgs {
@Field(() => ID, {
name: 'id',
description: 'ID of the Team Environment',
})
id: string;
@Field({
name: 'name',
description: 'Name of the Team Environment',
})
name: string;
@Field({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string;
}

View File

@@ -13,6 +13,11 @@ import { throwErr } from 'src/utils';
import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard';
import { TeamEnvironment } from './team-environments.model';
import { TeamEnvironmentsService } from './team-environments.service';
import * as E from 'fp-ts/Either';
import {
CreateTeamEnvironmentArgs,
UpdateTeamEnvironmentArgs,
} from './input-type.args';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => 'TeamEnvironment')
@@ -29,29 +34,18 @@ export class TeamEnvironmentsResolver {
})
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
createTeamEnvironment(
@Args({
name: 'name',
description: 'Name of the Team Environment',
})
name: string,
@Args({
name: 'teamID',
description: 'ID of the Team',
type: () => ID,
})
teamID: string,
@Args({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string,
async createTeamEnvironment(
@Args() args: CreateTeamEnvironmentArgs,
): Promise<TeamEnvironment> {
return this.teamEnvironmentsService.createTeamEnvironment(
name,
teamID,
variables,
)();
const teamEnvironment =
await this.teamEnvironmentsService.createTeamEnvironment(
args.name,
args.teamID,
args.variables,
);
if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left);
return teamEnvironment.right;
}
@Mutation(() => Boolean, {
@@ -59,7 +53,7 @@ export class TeamEnvironmentsResolver {
})
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
deleteTeamEnvironment(
async deleteTeamEnvironment(
@Args({
name: 'id',
description: 'ID of the Team Environment',
@@ -67,10 +61,12 @@ export class TeamEnvironmentsResolver {
})
id: string,
): Promise<boolean> {
return pipe(
this.teamEnvironmentsService.deleteTeamEnvironment(id),
TE.getOrElse(throwErr),
)();
const isDeleted = await this.teamEnvironmentsService.deleteTeamEnvironment(
id,
);
if (E.isLeft(isDeleted)) throwErr(isDeleted.left);
return isDeleted.right;
}
@Mutation(() => TeamEnvironment, {
@@ -79,28 +75,19 @@ export class TeamEnvironmentsResolver {
})
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
updateTeamEnvironment(
@Args({
name: 'id',
description: 'ID of the Team Environment',
type: () => ID,
})
id: string,
@Args({
name: 'name',
description: 'Name of the Team Environment',
})
name: string,
@Args({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string,
async updateTeamEnvironment(
@Args()
args: UpdateTeamEnvironmentArgs,
): Promise<TeamEnvironment> {
return pipe(
this.teamEnvironmentsService.updateTeamEnvironment(id, name, variables),
TE.getOrElse(throwErr),
)();
const updatedTeamEnvironment =
await this.teamEnvironmentsService.updateTeamEnvironment(
args.id,
args.name,
args.variables,
);
if (E.isLeft(updatedTeamEnvironment)) throwErr(updatedTeamEnvironment.left);
return updatedTeamEnvironment.right;
}
@Mutation(() => TeamEnvironment, {
@@ -108,7 +95,7 @@ export class TeamEnvironmentsResolver {
})
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
deleteAllVariablesFromTeamEnvironment(
async deleteAllVariablesFromTeamEnvironment(
@Args({
name: 'id',
description: 'ID of the Team Environment',
@@ -116,10 +103,13 @@ export class TeamEnvironmentsResolver {
})
id: string,
): Promise<TeamEnvironment> {
return pipe(
this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(id),
TE.getOrElse(throwErr),
)();
const teamEnvironment =
await this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
id,
);
if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left);
return teamEnvironment.right;
}
@Mutation(() => TeamEnvironment, {
@@ -127,7 +117,7 @@ export class TeamEnvironmentsResolver {
})
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
createDuplicateEnvironment(
async createDuplicateEnvironment(
@Args({
name: 'id',
description: 'ID of the Team Environment',
@@ -135,10 +125,12 @@ export class TeamEnvironmentsResolver {
})
id: string,
): Promise<TeamEnvironment> {
return pipe(
this.teamEnvironmentsService.createDuplicateEnvironment(id),
TE.getOrElse(throwErr),
)();
const res = await this.teamEnvironmentsService.createDuplicateEnvironment(
id,
);
if (E.isLeft(res)) throwErr(res.left);
return res.right;
}
/* Subscriptions */

View File

@@ -2,7 +2,11 @@ import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaService } from 'src/prisma/prisma.service';
import { TeamEnvironment } from './team-environments.model';
import { TeamEnvironmentsService } from './team-environments.service';
import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors';
import {
JSON_INVALID,
TEAM_ENVIRONMENT_NOT_FOUND,
TEAM_ENVIRONMENT_SHORT_NAME,
} from 'src/errors';
const mockPrisma = mockDeep<PrismaService>();
@@ -31,125 +35,81 @@ beforeEach(() => {
describe('TeamEnvironmentsService', () => {
describe('getTeamEnvironment', () => {
test('queries the db with the id', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment);
await teamEnvironmentsService.getTeamEnvironment('123')();
expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: '123',
},
}),
test('should successfully return a TeamEnvironment with valid ID', async () => {
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
teamEnvironment,
);
});
test('requests prisma to reject the query promise if not found', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment);
await teamEnvironmentsService.getTeamEnvironment('123')();
expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
rejectOnNotFound: true,
}),
const result = await teamEnvironmentsService.getTeamEnvironment(
teamEnvironment.id,
);
expect(result).toEqualRight(teamEnvironment);
});
test('should return a Some of the correct environment if exists', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment);
test('should throw TEAM_ENVIRONMENT_NOT_FOUND with invalid ID', async () => {
mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce(
'RejectOnNotFound',
);
const result = await teamEnvironmentsService.getTeamEnvironment('123')();
expect(result).toEqualSome(teamEnvironment);
});
test('should return a None if the environment does not exist', async () => {
mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError');
const result = await teamEnvironmentsService.getTeamEnvironment('123')();
expect(result).toBeNone();
const result = await teamEnvironmentsService.getTeamEnvironment(
teamEnvironment.id,
);
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
});
});
describe('createTeamEnvironment', () => {
test('should create and return a new team environment given a valid name,variable and team ID', async () => {
test('should successfully create and return a new team environment given valid inputs', async () => {
mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment);
const result = await teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name,
teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables),
)();
);
expect(result).toEqual(<TeamEnvironment>{
id: teamEnvironment.id,
name: teamEnvironment.name,
teamID: teamEnvironment.teamID,
expect(result).toEqualRight({
...teamEnvironment,
variables: JSON.stringify(teamEnvironment.variables),
});
});
test('should reject if given team ID is invalid', async () => {
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => {
const result = await teamEnvironmentsService.createTeamEnvironment(
'12',
teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables),
);
await expect(
teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name,
'invalidteamid',
JSON.stringify(teamEnvironment.variables),
),
).rejects.toBeDefined();
});
test('should reject if provided team environment name is not a string', async () => {
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
await expect(
teamEnvironmentsService.createTeamEnvironment(
null as any,
teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables),
),
).rejects.toBeDefined();
});
test('should reject if provided variable is not a string', async () => {
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
await expect(
teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name,
teamEnvironment.teamID,
null as any,
),
).rejects.toBeDefined();
expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME);
});
test('should send pubsub message to "team_environment/<teamID>/created" if team environment is created successfully', async () => {
mockPrisma.teamEnvironment.create.mockResolvedValueOnce(teamEnvironment);
mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment);
const result = await teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name,
teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables),
)();
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/created`,
result,
{
...teamEnvironment,
variables: JSON.stringify(teamEnvironment.variables),
},
);
});
});
describe('deleteTeamEnvironment', () => {
test('should resolve to true given a valid team environment ID', async () => {
test('should successfully delete a TeamEnvironment with a valid ID', async () => {
mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment);
const result = await teamEnvironmentsService.deleteTeamEnvironment(
teamEnvironment.id,
)();
);
expect(result).toEqualRight(true);
});
@@ -159,7 +119,7 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.deleteTeamEnvironment(
'invalidid',
)();
);
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
});
@@ -169,7 +129,7 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.deleteTeamEnvironment(
teamEnvironment.id,
)();
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/deleted`,
@@ -182,7 +142,7 @@ describe('TeamEnvironmentsService', () => {
});
describe('updateVariablesInTeamEnvironment', () => {
test('should add new variable to a team environment', async () => {
test('should successfully add new variable to a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment,
variables: [{ key: 'value' }],
@@ -192,7 +152,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id,
teamEnvironment.name,
JSON.stringify([{ key: 'value' }]),
)();
);
expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment,
@@ -200,7 +160,7 @@ describe('TeamEnvironmentsService', () => {
});
});
test('should add new variable to already existing list of variables in a team environment', async () => {
test('should successfully add new variable to already existing list of variables in a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment,
variables: [{ key: 'value' }, { key_2: 'value_2' }],
@@ -210,7 +170,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id,
teamEnvironment.name,
JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]),
)();
);
expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment,
@@ -218,7 +178,7 @@ describe('TeamEnvironmentsService', () => {
});
});
test('should edit existing variables in a team environment', async () => {
test('should successfully edit existing variables in a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment,
variables: [{ key: '1234' }],
@@ -228,7 +188,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id,
teamEnvironment.name,
JSON.stringify([{ key: '1234' }]),
)();
);
expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment,
@@ -236,22 +196,7 @@ describe('TeamEnvironmentsService', () => {
});
});
test('should delete existing variable in a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment);
const result = await teamEnvironmentsService.updateTeamEnvironment(
teamEnvironment.id,
teamEnvironment.name,
JSON.stringify([{}]),
)();
expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment,
variables: JSON.stringify([{}]),
});
});
test('should edit name of an existing team environment', async () => {
test('should successfully edit name of an existing team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment,
variables: [{ key: '123' }],
@@ -261,7 +206,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id,
teamEnvironment.name,
JSON.stringify([{ key: '123' }]),
)();
);
expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment,
@@ -269,14 +214,24 @@ describe('TeamEnvironmentsService', () => {
});
});
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => {
const result = await teamEnvironmentsService.updateTeamEnvironment(
teamEnvironment.id,
'12',
JSON.stringify([{ key: 'value' }]),
);
expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME);
});
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
const result = await teamEnvironmentsService.updateTeamEnvironment(
'invalidid',
teamEnvironment.name,
JSON.stringify(teamEnvironment.variables),
)();
);
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
});
@@ -288,7 +243,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id,
teamEnvironment.name,
JSON.stringify([{ key: 'value' }]),
)();
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/updated`,
@@ -301,13 +256,13 @@ describe('TeamEnvironmentsService', () => {
});
describe('deleteAllVariablesFromTeamEnvironment', () => {
test('should delete all variables in a team environment', async () => {
test('should successfully delete all variables in a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment);
const result =
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
teamEnvironment.id,
)();
);
expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment,
@@ -315,13 +270,13 @@ describe('TeamEnvironmentsService', () => {
});
});
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
const result =
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
'invalidid',
)();
);
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
});
@@ -332,7 +287,7 @@ describe('TeamEnvironmentsService', () => {
const result =
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
teamEnvironment.id,
)();
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/updated`,
@@ -345,33 +300,33 @@ describe('TeamEnvironmentsService', () => {
});
describe('createDuplicateEnvironment', () => {
test('should duplicate an existing team environment', async () => {
test('should successfully duplicate an existing team environment', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(
teamEnvironment,
);
mockPrisma.teamEnvironment.create.mockResolvedValueOnce({
...teamEnvironment,
id: 'newid',
...teamEnvironment,
});
const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id,
)();
);
expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment,
id: 'newid',
...teamEnvironment,
variables: JSON.stringify(teamEnvironment.variables),
});
});
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError');
const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id,
)();
);
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
});
@@ -382,19 +337,19 @@ describe('TeamEnvironmentsService', () => {
);
mockPrisma.teamEnvironment.create.mockResolvedValueOnce({
...teamEnvironment,
id: 'newid',
...teamEnvironment,
});
const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id,
)();
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/created`,
{
...teamEnvironment,
id: 'newid',
...teamEnvironment,
variables: JSON.stringify([{}]),
},
);

View File

@@ -1,15 +1,14 @@
import { Injectable } from '@nestjs/common';
import { pipe } from 'fp-ts/function';
import * as T from 'fp-ts/Task';
import * as TO from 'fp-ts/TaskOption';
import * as TE from 'fp-ts/TaskEither';
import * as A from 'fp-ts/Array';
import { Prisma } from '@prisma/client';
import { TeamEnvironment as DBTeamEnvironment, Prisma } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { TeamEnvironment } from './team-environments.model';
import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors';
import {
TEAM_ENVIRONMENT_NOT_FOUND,
TEAM_ENVIRONMENT_SHORT_NAME,
} from 'src/errors';
import * as E from 'fp-ts/Either';
import { isValidLength } from 'src/utils';
@Injectable()
export class TeamEnvironmentsService {
constructor(
@@ -17,219 +16,218 @@ export class TeamEnvironmentsService {
private readonly pubsub: PubSubService,
) {}
getTeamEnvironment(id: string) {
return TO.tryCatch(() =>
this.prisma.teamEnvironment.findFirst({
where: { id },
TITLE_LENGTH = 3;
/**
* TeamEnvironments are saved in the DB in the following way
* [{ key: value }, { key: value },....]
*
*/
/**
* Typecast a database TeamEnvironment to a TeamEnvironment model
* @param teamEnvironment database TeamEnvironment
* @returns TeamEnvironment model
*/
private cast(teamEnvironment: DBTeamEnvironment): TeamEnvironment {
return {
id: teamEnvironment.id,
name: teamEnvironment.name,
teamID: teamEnvironment.teamID,
variables: JSON.stringify(teamEnvironment.variables),
};
}
/**
* Get details of a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of a TeamEnvironment or error message
*/
async getTeamEnvironment(id: string) {
try {
const teamEnvironment =
await this.prisma.teamEnvironment.findFirstOrThrow({
where: { id },
});
return E.right(teamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Create a new TeamEnvironment.
*
* @param name name of new TeamEnvironment
* @param teamID teamID of new TeamEnvironment
* @param variables JSONified string of contents of new TeamEnvironment
* @returns Either of a TeamEnvironment or error message
*/
async createTeamEnvironment(name: string, teamID: string, variables: string) {
const isTitleValid = isValidLength(name, this.TITLE_LENGTH);
if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME);
const result = await this.prisma.teamEnvironment.create({
data: {
name: name,
teamID: teamID,
variables: JSON.parse(variables),
},
});
const createdTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${createdTeamEnvironment.teamID}/created`,
createdTeamEnvironment,
);
return E.right(createdTeamEnvironment);
}
/**
* Delete a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of boolean or error message
*/
async deleteTeamEnvironment(id: string) {
try {
const result = await this.prisma.teamEnvironment.delete({
where: {
id: id,
},
});
const deletedTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${deletedTeamEnvironment.teamID}/deleted`,
deletedTeamEnvironment,
);
return E.right(true);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Update a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @param name TeamEnvironment name
* @param variables JSONified string of contents of new TeamEnvironment
* @returns Either of a TeamEnvironment or error message
*/
async updateTeamEnvironment(id: string, name: string, variables: string) {
try {
const isTitleValid = isValidLength(name, this.TITLE_LENGTH);
if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME);
const result = await this.prisma.teamEnvironment.update({
where: { id: id },
data: {
name,
variables: JSON.parse(variables),
},
});
const updatedTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${updatedTeamEnvironment.teamID}/updated`,
updatedTeamEnvironment,
);
return E.right(updatedTeamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Clear contents of a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of a TeamEnvironment or error message
*/
async deleteAllVariablesFromTeamEnvironment(id: string) {
try {
const result = await this.prisma.teamEnvironment.update({
where: { id: id },
data: {
variables: [],
},
});
const teamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${teamEnvironment.teamID}/updated`,
teamEnvironment,
);
return E.right(teamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Create a duplicate of a existing TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of a TeamEnvironment or error message
*/
async createDuplicateEnvironment(id: string) {
try {
const environment = await this.prisma.teamEnvironment.findFirst({
where: {
id: id,
},
rejectOnNotFound: true,
}),
);
});
const result = await this.prisma.teamEnvironment.create({
data: {
name: environment.name,
teamID: environment.teamID,
variables: environment.variables as Prisma.JsonArray,
},
});
const duplicatedTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${duplicatedTeamEnvironment.teamID}/created`,
duplicatedTeamEnvironment,
);
return E.right(duplicatedTeamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
createTeamEnvironment(name: string, teamID: string, variables: string) {
return pipe(
() =>
this.prisma.teamEnvironment.create({
data: {
name: name,
teamID: teamID,
variables: JSON.parse(variables),
},
}),
T.chainFirst(
(environment) => () =>
this.pubsub.publish(
`team_environment/${environment.teamID}/created`,
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
T.map((data) => {
return <TeamEnvironment>{
id: data.id,
name: data.name,
teamID: data.teamID,
variables: JSON.stringify(data.variables),
};
}),
);
}
/**
* Fetch all TeamEnvironments of a team.
*
* @param teamID teamID of new TeamEnvironment
* @returns List of TeamEnvironments
*/
async fetchAllTeamEnvironments(teamID: string) {
const result = await this.prisma.teamEnvironment.findMany({
where: {
teamID: teamID,
},
});
const teamEnvironments = result.map((item) => {
return this.cast(item);
});
deleteTeamEnvironment(id: string) {
return pipe(
TE.tryCatch(
() =>
this.prisma.teamEnvironment.delete({
where: {
id: id,
},
}),
() => TEAM_ENVIRONMENT_NOT_FOUND,
),
TE.chainFirst((environment) =>
TE.fromTask(() =>
this.pubsub.publish(
`team_environment/${environment.teamID}/deleted`,
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
),
TE.map((data) => true),
);
}
updateTeamEnvironment(id: string, name: string, variables: string) {
return pipe(
TE.tryCatch(
() =>
this.prisma.teamEnvironment.update({
where: { id: id },
data: {
name,
variables: JSON.parse(variables),
},
}),
() => TEAM_ENVIRONMENT_NOT_FOUND,
),
TE.chainFirst((environment) =>
TE.fromTask(() =>
this.pubsub.publish(
`team_environment/${environment.teamID}/updated`,
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
),
TE.map(
(environment) =>
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
);
}
deleteAllVariablesFromTeamEnvironment(id: string) {
return pipe(
TE.tryCatch(
() =>
this.prisma.teamEnvironment.update({
where: { id: id },
data: {
variables: [],
},
}),
() => TEAM_ENVIRONMENT_NOT_FOUND,
),
TE.chainFirst((environment) =>
TE.fromTask(() =>
this.pubsub.publish(
`team_environment/${environment.teamID}/updated`,
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
),
TE.map(
(environment) =>
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
);
}
createDuplicateEnvironment(id: string) {
return pipe(
TE.tryCatch(
() =>
this.prisma.teamEnvironment.findFirst({
where: {
id: id,
},
rejectOnNotFound: true,
}),
() => TEAM_ENVIRONMENT_NOT_FOUND,
),
TE.chain((environment) =>
TE.fromTask(() =>
this.prisma.teamEnvironment.create({
data: {
name: environment.name,
teamID: environment.teamID,
variables: environment.variables as Prisma.JsonArray,
},
}),
),
),
TE.chainFirst((environment) =>
TE.fromTask(() =>
this.pubsub.publish(
`team_environment/${environment.teamID}/created`,
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
),
TE.map(
(environment) =>
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
);
}
fetchAllTeamEnvironments(teamID: string) {
return pipe(
() =>
this.prisma.teamEnvironment.findMany({
where: {
teamID: teamID,
},
}),
T.map(
A.map(
(environment) =>
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
);
return teamEnvironments;
}
/**

View File

@@ -11,6 +11,6 @@ export class TeamEnvsTeamResolver {
description: 'Returns all Team Environments for the given Team',
})
teamEnvironments(@Parent() team: Team): Promise<TeamEnvironment[]> {
return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id)();
return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id);
}
}

View File

@@ -0,0 +1,20 @@
import { ArgsType, Field, ID } from '@nestjs/graphql';
import { TeamMemberRole } from 'src/team/team.model';
@ArgsType()
export class CreateTeamInvitationArgs {
@Field(() => ID, {
name: 'teamID',
description: 'ID of the Team ID to invite from',
})
teamID: string;
@Field({ name: 'inviteeEmail', description: 'Email of the user to invite' })
inviteeEmail: string;
@Field(() => TeamMemberRole, {
name: 'inviteeRole',
description: 'Role to be given to the user',
})
inviteeRole: TeamMemberRole;
}

View File

@@ -12,15 +12,10 @@ import { TeamInvitation } from './team-invitation.model';
import { TeamInvitationService } from './team-invitation.service';
import { pipe } from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option';
import { Team, TeamMember, TeamMemberRole } from 'src/team/team.model';
import { EmailCodec } from 'src/types/Email';
import {
INVALID_EMAIL,
TEAM_INVITE_EMAIL_DO_NOT_MATCH,
TEAM_INVITE_NO_INVITE_FOUND,
USER_NOT_FOUND,
} from 'src/errors';
import { TEAM_INVITE_NO_INVITE_FOUND, USER_NOT_FOUND } from 'src/errors';
import { GqlUser } from 'src/decorators/gql-user.decorator';
import { User } from 'src/user/user.model';
import { UseGuards } from '@nestjs/common';
@@ -36,6 +31,8 @@ import { UserService } from 'src/user/user.service';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { SkipThrottle } from '@nestjs/throttler';
import { AuthUser } from 'src/types/AuthUser';
import { CreateTeamInvitationArgs } from './input-type.args';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => TeamInvitation)
@@ -79,8 +76,8 @@ export class TeamInvitationResolver {
'Gets the Team Invitation with the given ID, or null if not exists',
})
@UseGuards(GqlAuthGuard, TeamInviteViewerGuard)
teamInvitation(
@GqlUser() user: User,
async teamInvitation(
@GqlUser() user: AuthUser,
@Args({
name: 'inviteID',
description: 'ID of the Team Invitation to lookup',
@@ -88,17 +85,11 @@ export class TeamInvitationResolver {
})
inviteID: string,
): Promise<TeamInvitation> {
return pipe(
this.teamInvitationService.getInvitation(inviteID),
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
TE.chainW(
TE.fromPredicate(
(a) => a.inviteeEmail.toLowerCase() === user.email?.toLowerCase(),
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
),
),
TE.getOrElse(throwErr),
)();
const teamInvitation = await this.teamInvitationService.getInvitation(
inviteID,
);
if (O.isNone(teamInvitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
return teamInvitation.value;
}
@Mutation(() => TeamInvitation, {
@@ -106,56 +97,19 @@ export class TeamInvitationResolver {
})
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER)
createTeamInvitation(
@GqlUser()
user: User,
@Args({
name: 'teamID',
description: 'ID of the Team ID to invite from',
type: () => ID,
})
teamID: string,
@Args({
name: 'inviteeEmail',
description: 'Email of the user to invite',
})
inviteeEmail: string,
@Args({
name: 'inviteeRole',
type: () => TeamMemberRole,
description: 'Role to be given to the user',
})
inviteeRole: TeamMemberRole,
async createTeamInvitation(
@GqlUser() user: AuthUser,
@Args() args: CreateTeamInvitationArgs,
): Promise<TeamInvitation> {
return pipe(
TE.Do,
const teamInvitation = await this.teamInvitationService.createInvitation(
user,
args.teamID,
args.inviteeEmail,
args.inviteeRole,
);
// Validate email
TE.bindW('email', () =>
pipe(
EmailCodec.decode(inviteeEmail),
TE.fromEither,
TE.mapLeft(() => INVALID_EMAIL),
),
),
// Validate and get Team
TE.bindW('team', () => this.teamService.getTeamWithIDTE(teamID)),
// Create team
TE.chainW(({ email, team }) =>
this.teamInvitationService.createInvitation(
user,
team,
email,
inviteeRole,
),
),
// If failed, throw err (so the message is passed) else return value
TE.getOrElse(throwErr),
)();
if (E.isLeft(teamInvitation)) throwErr(teamInvitation.left);
return teamInvitation.right;
}
@Mutation(() => Boolean, {
@@ -163,7 +117,7 @@ export class TeamInvitationResolver {
})
@UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard)
@RequiresTeamRole(TeamMemberRole.OWNER)
revokeTeamInvitation(
async revokeTeamInvitation(
@Args({
name: 'inviteID',
type: () => ID,
@@ -171,19 +125,19 @@ export class TeamInvitationResolver {
})
inviteID: string,
): Promise<true> {
return pipe(
this.teamInvitationService.revokeInvitation(inviteID),
TE.map(() => true as const),
TE.getOrElse(throwErr),
)();
const isRevoked = await this.teamInvitationService.revokeInvitation(
inviteID,
);
if (E.isLeft(isRevoked)) throwErr(isRevoked.left);
return true;
}
@Mutation(() => TeamMember, {
description: 'Accept an Invitation',
})
@UseGuards(GqlAuthGuard, TeamInviteeGuard)
acceptTeamInvitation(
@GqlUser() user: User,
async acceptTeamInvitation(
@GqlUser() user: AuthUser,
@Args({
name: 'inviteID',
type: () => ID,
@@ -191,10 +145,12 @@ export class TeamInvitationResolver {
})
inviteID: string,
): Promise<TeamMember> {
return pipe(
this.teamInvitationService.acceptInvitation(inviteID, user),
TE.getOrElse(throwErr),
)();
const teamMember = await this.teamInvitationService.acceptInvitation(
inviteID,
user,
);
if (E.isLeft(teamMember)) throwErr(teamMember.left);
return teamMember.right;
}
// Subscriptions

View File

@@ -1,24 +1,25 @@
import { Injectable } from '@nestjs/common';
import * as T from 'fp-ts/Task';
import * as O from 'fp-ts/Option';
import * as TO from 'fp-ts/TaskOption';
import * as TE from 'fp-ts/TaskEither';
import { pipe, flow, constVoid } from 'fp-ts/function';
import * as E from 'fp-ts/Either';
import { PrismaService } from 'src/prisma/prisma.service';
import { Team, TeamMemberRole } from 'src/team/team.model';
import { Email } from 'src/types/Email';
import { User } from 'src/user/user.model';
import { TeamInvitation as DBTeamInvitation } from '@prisma/client';
import { TeamMember, TeamMemberRole } from 'src/team/team.model';
import { TeamService } from 'src/team/team.service';
import {
INVALID_EMAIL,
TEAM_INVALID_ID,
TEAM_INVITE_ALREADY_MEMBER,
TEAM_INVITE_EMAIL_DO_NOT_MATCH,
TEAM_INVITE_MEMBER_HAS_INVITE,
TEAM_INVITE_NO_INVITE_FOUND,
TEAM_MEMBER_NOT_FOUND,
} from 'src/errors';
import { TeamInvitation } from './team-invitation.model';
import { MailerService } from 'src/mailer/mailer.service';
import { UserService } from 'src/user/user.service';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { validateEmail } from '../utils';
import { AuthUser } from 'src/types/AuthUser';
@Injectable()
export class TeamInvitationService {
@@ -29,245 +30,221 @@ export class TeamInvitationService {
private readonly mailerService: MailerService,
private readonly pubsub: PubSubService,
) {
this.getInvitation = this.getInvitation.bind(this);
}
) {}
getInvitation(inviteID: string): TO.TaskOption<TeamInvitation> {
return pipe(
() =>
this.prisma.teamInvitation.findUnique({
where: {
id: inviteID,
},
}),
TO.fromTask,
TO.chain(flow(O.fromNullable, TO.fromOption)),
TO.map((x) => x as TeamInvitation),
);
}
getInvitationWithEmail(email: Email, team: Team) {
return pipe(
() =>
this.prisma.teamInvitation.findUnique({
where: {
teamID_inviteeEmail: {
inviteeEmail: email,
teamID: team.id,
},
},
}),
TO.fromTask,
TO.chain(flow(O.fromNullable, TO.fromOption)),
);
}
createInvitation(
creator: User,
team: Team,
inviteeEmail: Email,
inviteeRole: TeamMemberRole,
) {
return pipe(
// Perform all validation checks
TE.sequenceArray([
// creator should be a TeamMember
pipe(
this.teamService.getTeamMemberTE(team.id, creator.uid),
TE.map(constVoid),
),
// Invitee should not be a team member
pipe(
async () => await this.userService.findUserByEmail(inviteeEmail),
TO.foldW(
() => TE.right(undefined), // If no user, short circuit to completion
(user) =>
pipe(
// If user is found, check if team member
this.teamService.getTeamMemberTE(team.id, user.uid),
TE.foldW(
() => TE.right(undefined), // Not team-member, this is good
() => TE.left(TEAM_INVITE_ALREADY_MEMBER), // Is team member, not good
),
),
),
TE.map(constVoid),
),
// Should not have an existing invite
pipe(
this.getInvitationWithEmail(inviteeEmail, team),
TE.fromTaskOption(() => null),
TE.swap,
TE.map(constVoid),
TE.mapLeft(() => TEAM_INVITE_MEMBER_HAS_INVITE),
),
]),
// Create the invitation
TE.chainTaskK(
() => () =>
this.prisma.teamInvitation.create({
data: {
teamID: team.id,
inviteeEmail,
inviteeRole,
creatorUid: creator.uid,
},
}),
),
// Send email, this is a side effect
TE.chainFirstTaskK((invitation) =>
pipe(
this.mailerService.sendMail(inviteeEmail, {
template: 'team-invitation',
variables: {
invitee: creator.displayName ?? 'A Hoppscotch User',
action_url: `${process.env.VITE_BASE_URL}/join-team?id=${invitation.id}`,
invite_team_name: team.name,
},
}),
TE.getOrElseW(() => T.of(undefined)), // This value doesn't matter as we don't mind the return value (chainFirst) as long as the task completes
),
),
// Send PubSub topic
TE.chainFirstTaskK((invitation) =>
TE.fromTask(async () => {
const inv: TeamInvitation = {
id: invitation.id,
teamID: invitation.teamID,
creatorUid: invitation.creatorUid,
inviteeEmail: invitation.inviteeEmail,
inviteeRole: TeamMemberRole[invitation.inviteeRole],
};
this.pubsub.publish(`team/${inv.teamID}/invite_added`, inv);
}),
),
// Map to model type
TE.map((x) => x as TeamInvitation),
);
}
revokeInvitation(inviteID: string) {
return pipe(
// Make sure invite exists
this.getInvitation(inviteID),
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
// Delete team invitation
TE.chainTaskK(
() => () =>
this.prisma.teamInvitation.delete({
where: {
id: inviteID,
},
}),
),
// Emit Pubsub Event
TE.chainFirst((invitation) =>
TE.fromTask(() =>
this.pubsub.publish(
`team/${invitation.teamID}/invite_removed`,
invitation.id,
),
),
),
// We are not returning anything
TE.map(constVoid),
);
}
getAllInvitationsInTeam(team: Team) {
return pipe(
() =>
this.prisma.teamInvitation.findMany({
where: {
teamID: team.id,
},
}),
T.map((x) => x as TeamInvitation[]),
);
}
acceptInvitation(inviteID: string, acceptedBy: User) {
return pipe(
TE.Do,
// First get the invitation
TE.bindW('invitation', () =>
pipe(
this.getInvitation(inviteID),
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
// Validation checks
TE.chainFirstW(({ invitation }) =>
TE.sequenceArray([
// Make sure the invited user is not part of the team
pipe(
this.teamService.getTeamMemberTE(invitation.teamID, acceptedBy.uid),
TE.swap,
TE.bimap(
() => TEAM_INVITE_ALREADY_MEMBER,
constVoid, // The return type is ignored
),
),
// Make sure the invited user and accepting user has the same email
pipe(
undefined,
TE.fromPredicate(
(a) => acceptedBy.email === invitation.inviteeEmail,
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
),
),
]),
),
// Add the team member
// TODO: Somehow bring subscriptions to this ?
TE.bindW('teamMember', ({ invitation }) =>
pipe(
TE.tryCatch(
() =>
this.teamService.addMemberToTeam(
invitation.teamID,
acceptedBy.uid,
invitation.inviteeRole,
),
() => TEAM_INVITE_ALREADY_MEMBER, // Can only fail if Team Member already exists, which we checked, but due to async lets assert that here too
),
),
),
TE.chainFirstW(({ invitation }) => this.revokeInvitation(invitation.id)),
TE.map(({ teamMember }) => teamMember),
);
/**
* Cast a DBTeamInvitation to a TeamInvitation
* @param dbTeamInvitation database TeamInvitation
* @returns TeamInvitation model
*/
cast(dbTeamInvitation: DBTeamInvitation): TeamInvitation {
return {
...dbTeamInvitation,
inviteeRole: TeamMemberRole[dbTeamInvitation.inviteeRole],
};
}
/**
* Fetch the count invitations for a given team.
* @param teamID team id
* @returns a count team invitations for a team
* Get the team invite
* @param inviteID invite id
* @returns an Option of team invitation or none
*/
async getAllTeamInvitations(teamID: string) {
const invitations = await this.prisma.teamInvitation.findMany({
async getInvitation(inviteID: string) {
try {
const dbInvitation = await this.prisma.teamInvitation.findUniqueOrThrow({
where: {
id: inviteID,
},
});
return O.some(this.cast(dbInvitation));
} catch (e) {
return O.none;
}
}
/**
* Get the team invite for an invitee with email and teamID.
* @param inviteeEmail invitee email
* @param teamID team id
* @returns an Either of team invitation for the invitee or error
*/
async getTeamInviteByEmailAndTeamID(inviteeEmail: string, teamID: string) {
const isEmailValid = validateEmail(inviteeEmail);
if (!isEmailValid) return E.left(INVALID_EMAIL);
try {
const teamInvite = await this.prisma.teamInvitation.findUniqueOrThrow({
where: {
teamID_inviteeEmail: {
inviteeEmail: inviteeEmail,
teamID: teamID,
},
},
});
return E.right(teamInvite);
} catch (e) {
return E.left(TEAM_INVITE_NO_INVITE_FOUND);
}
}
/**
* Create a team invitation
* @param creator creator of the invitation
* @param teamID team id
* @param inviteeEmail invitee email
* @param inviteeRole invitee role
* @returns an Either of team invitation or error message
*/
async createInvitation(
creator: AuthUser,
teamID: string,
inviteeEmail: string,
inviteeRole: TeamMemberRole,
) {
// validate email
const isEmailValid = validateEmail(inviteeEmail);
if (!isEmailValid) return E.left(INVALID_EMAIL);
// team ID should valid
const team = await this.teamService.getTeamWithID(teamID);
if (!team) return E.left(TEAM_INVALID_ID);
// invitation creator should be a TeamMember
const isTeamMember = await this.teamService.getTeamMember(
team.id,
creator.uid,
);
if (!isTeamMember) return E.left(TEAM_MEMBER_NOT_FOUND);
// Checking to see if the invitee is already part of the team or not
const inviteeUser = await this.userService.findUserByEmail(inviteeEmail);
if (O.isSome(inviteeUser)) {
// invitee should not already a member
const isTeamMember = await this.teamService.getTeamMember(
team.id,
inviteeUser.value.uid,
);
if (isTeamMember) return E.left(TEAM_INVITE_ALREADY_MEMBER);
}
// check invitee already invited earlier or not
const teamInvitation = await this.getTeamInviteByEmailAndTeamID(
inviteeEmail,
team.id,
);
if (E.isRight(teamInvitation)) return E.left(TEAM_INVITE_MEMBER_HAS_INVITE);
// create the invitation
const dbInvitation = await this.prisma.teamInvitation.create({
data: {
teamID: team.id,
inviteeEmail,
inviteeRole,
creatorUid: creator.uid,
},
});
await this.mailerService.sendEmail(inviteeEmail, {
template: 'team-invitation',
variables: {
invitee: creator.displayName ?? 'A Hoppscotch User',
action_url: `${process.env.VITE_BASE_URL}/join-team?id=${dbInvitation.id}`,
invite_team_name: team.name,
},
});
const invitation = this.cast(dbInvitation);
this.pubsub.publish(`team/${invitation.teamID}/invite_added`, invitation);
return E.right(invitation);
}
/**
* Revoke a team invitation
* @param inviteID invite id
* @returns an Either of true or error message
*/
async revokeInvitation(inviteID: string) {
// check if the invite exists
const invitation = await this.getInvitation(inviteID);
if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND);
// delete the invite
await this.prisma.teamInvitation.delete({
where: {
id: inviteID,
},
});
this.pubsub.publish(
`team/${invitation.value.teamID}/invite_removed`,
invitation.value.id,
);
return E.right(true);
}
/**
* Accept a team invitation
* @param inviteID invite id
* @param acceptedBy user who accepted the invitation
* @returns an Either of team member or error message
*/
async acceptInvitation(inviteID: string, acceptedBy: AuthUser) {
// check if the invite exists
const invitation = await this.getInvitation(inviteID);
if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND);
// make sure the user is not already a member of the team
const teamMemberInvitee = await this.teamService.getTeamMember(
invitation.value.teamID,
acceptedBy.uid,
);
if (teamMemberInvitee) return E.left(TEAM_INVITE_ALREADY_MEMBER);
// make sure the user is the same as the invitee
if (
acceptedBy.email.toLowerCase() !==
invitation.value.inviteeEmail.toLowerCase()
)
return E.left(TEAM_INVITE_EMAIL_DO_NOT_MATCH);
// add the user to the team
let teamMember: TeamMember;
try {
teamMember = await this.teamService.addMemberToTeam(
invitation.value.teamID,
acceptedBy.uid,
invitation.value.inviteeRole,
);
} catch (e) {
return E.left(TEAM_INVITE_ALREADY_MEMBER);
}
// delete the invite
await this.revokeInvitation(inviteID);
return E.right(teamMember);
}
/**
* Fetch all team invitations for a given team.
* @param teamID team id
* @returns array of team invitations for a team
*/
async getTeamInvitations(teamID: string) {
const dbInvitations = await this.prisma.teamInvitation.findMany({
where: {
teamID: teamID,
},
});
const invitations: TeamInvitation[] = dbInvitations.map((dbInvitation) =>
this.cast(dbInvitation),
);
return invitations;
}
}

View File

@@ -1,21 +1,21 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { pipe } from 'fp-ts/function';
import { TeamService } from 'src/team/team.service';
import { TeamInvitationService } from './team-invitation.service';
import * as O from 'fp-ts/Option';
import * as T from 'fp-ts/Task';
import * as TE from 'fp-ts/TaskEither';
import { GqlExecutionContext } from '@nestjs/graphql';
import {
BUG_AUTH_NO_USER_CTX,
BUG_TEAM_INVITE_NO_INVITE_ID,
TEAM_INVITE_NO_INVITE_FOUND,
TEAM_MEMBER_NOT_FOUND,
TEAM_NOT_REQUIRED_ROLE,
} from 'src/errors';
import { User } from 'src/user/user.model';
import { throwErr } from 'src/utils';
import { TeamMemberRole } from 'src/team/team.model';
/**
* This guard only allows team owner to execute the resolver
*/
@Injectable()
export class TeamInviteTeamOwnerGuard implements CanActivate {
constructor(
@@ -24,48 +24,30 @@ export class TeamInviteTeamOwnerGuard implements CanActivate {
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
return pipe(
TE.Do,
// Get GQL context
const gqlExecCtx = GqlExecutionContext.create(context);
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
// Get user
const { user } = gqlExecCtx.getContext().req;
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
// Get the invite
TE.bindW('invite', ({ gqlCtx }) =>
pipe(
O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID),
TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID),
TE.chainW((inviteID) =>
pipe(
this.teamInviteService.getInvitation(inviteID),
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
),
),
// Get the invite
const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
TE.bindW('user', ({ gqlCtx }) =>
pipe(
gqlCtx.getContext().req.user,
O.fromNullable,
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
const invitation = await this.teamInviteService.getInvitation(inviteID);
if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
TE.bindW('userMember', ({ invite, user }) =>
this.teamService.getTeamMemberTE(invite.teamID, user.uid),
),
// Fetch team member details of this user
const teamMember = await this.teamService.getTeamMember(
invitation.value.teamID,
user.uid,
);
TE.chainW(
TE.fromPredicate(
({ userMember }) => userMember.role === TeamMemberRole.OWNER,
() => TEAM_NOT_REQUIRED_ROLE,
),
),
if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND);
if (teamMember.role !== TeamMemberRole.OWNER)
throwErr(TEAM_NOT_REQUIRED_ROLE);
TE.fold(
(err) => throwErr(err),
() => T.of(true),
),
)();
return true;
}
}

View File

@@ -1,20 +1,23 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { TeamInvitationService } from './team-invitation.service';
import { pipe, flow } from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither';
import * as T from 'fp-ts/Task';
import * as O from 'fp-ts/Option';
import { GqlExecutionContext } from '@nestjs/graphql';
import {
BUG_AUTH_NO_USER_CTX,
BUG_TEAM_INVITE_NO_INVITE_ID,
TEAM_INVITE_NOT_VALID_VIEWER,
TEAM_INVITE_NO_INVITE_FOUND,
TEAM_MEMBER_NOT_FOUND,
} from 'src/errors';
import { User } from 'src/user/user.model';
import { throwErr } from 'src/utils';
import { TeamService } from 'src/team/team.service';
/**
* This guard only allows user to execute the resolver
* 1. If user is invitee, allow
* 2. Or else, if user is team member, allow
*
* TLDR: Allow if user is invitee or team member
*/
@Injectable()
export class TeamInviteViewerGuard implements CanActivate {
constructor(
@@ -23,50 +26,32 @@ export class TeamInviteViewerGuard implements CanActivate {
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
return pipe(
TE.Do,
// Get GQL context
const gqlExecCtx = GqlExecutionContext.create(context);
// Get GQL Context
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
// Get user
const { user } = gqlExecCtx.getContext().req;
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
// Get user
TE.bindW('user', ({ gqlCtx }) =>
pipe(
O.fromNullable(gqlCtx.getContext().req.user),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
// Get the invite
const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
// Get the invite
TE.bindW('invite', ({ gqlCtx }) =>
pipe(
O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID),
TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID),
TE.chainW(
flow(
this.teamInviteService.getInvitation,
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
),
),
const invitation = await this.teamInviteService.getInvitation(inviteID);
if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
// Check if the user and the invite email match, else if we can resolver the user as a team member
// any better solution ?
TE.chainW(({ user, invite }) =>
user.email?.toLowerCase() === invite.inviteeEmail.toLowerCase()
? TE.of(true)
: pipe(
this.teamService.getTeamMemberTE(invite.teamID, user.uid),
TE.map(() => true),
),
),
// Check if the user and the invite email match, else if user is a team member
if (
user.email?.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase()
) {
const teamMember = await this.teamService.getTeamMember(
invitation.value.teamID,
user.uid,
);
TE.mapLeft((e) =>
e === 'team/member_not_found' ? TEAM_INVITE_NOT_VALID_VIEWER : e,
),
if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND);
}
TE.fold(throwErr, () => T.of(true)),
)();
return true;
}
}

View File

@@ -1,11 +1,7 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { TeamInvitationService } from './team-invitation.service';
import { pipe, flow } from 'fp-ts/function';
import * as O from 'fp-ts/Option';
import * as T from 'fp-ts/Task';
import * as TE from 'fp-ts/TaskEither';
import { GqlExecutionContext } from '@nestjs/graphql';
import { User } from 'src/user/user.model';
import {
BUG_AUTH_NO_USER_CTX,
BUG_TEAM_INVITE_NO_INVITE_ID,
@@ -24,44 +20,26 @@ export class TeamInviteeGuard implements CanActivate {
constructor(private readonly teamInviteService: TeamInvitationService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
return pipe(
TE.Do,
// Get GQL Context
const gqlExecCtx = GqlExecutionContext.create(context);
// Get execution context
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
// Get user
const { user } = gqlExecCtx.getContext().req;
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
// Get user
TE.bindW('user', ({ gqlCtx }) =>
pipe(
O.fromNullable(gqlCtx.getContext().req.user),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
// Get the invite
const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
// Get invite
TE.bindW('invite', ({ gqlCtx }) =>
pipe(
O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID),
TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID),
TE.chainW(
flow(
this.teamInviteService.getInvitation,
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
),
),
const invitation = await this.teamInviteService.getInvitation(inviteID);
if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
// Check if the emails match
TE.chainW(
TE.fromPredicate(
({ user, invite }) => user.email === invite.inviteeEmail,
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
),
),
if (
user.email.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase()
) {
throwErr(TEAM_INVITE_EMAIL_DO_NOT_MATCH);
}
// Fold it to a promise
TE.fold(throwErr, () => T.of(true)),
)();
return true;
}
}

View File

@@ -12,6 +12,6 @@ export class TeamTeamInviteExtResolver {
complexity: 10,
})
teamInvitations(@Parent() team: Team): Promise<TeamInvitation[]> {
return this.teamInviteService.getAllInvitationsInTeam(team)();
return this.teamInviteService.getTeamInvitations(team.id);
}
}

View File

@@ -9,7 +9,13 @@ import * as E from 'fp-ts/Either';
import * as A from 'fp-ts/Array';
import { TeamMemberRole } from './team/team.model';
import { User } from './user/user.model';
import { JSON_INVALID } from './errors';
import {
ENV_EMPTY_AUTH_PROVIDERS,
ENV_NOT_FOUND_KEY_AUTH_PROVIDERS,
ENV_NOT_SUPPORT_AUTH_PROVIDERS,
JSON_INVALID,
} from './errors';
import { AuthProvider } from './auth/helper';
/**
* A workaround to throw an exception in an expression.
@@ -152,3 +158,31 @@ export function isValidLength(title: string, length: number) {
return true;
}
/**
* This function is called by bootstrap() in main.ts
* It checks if the "VITE_ALLOWED_AUTH_PROVIDERS" environment variable is properly set or not.
* If not, it throws an error.
*/
export function checkEnvironmentAuthProvider() {
if (!process.env.hasOwnProperty('VITE_ALLOWED_AUTH_PROVIDERS')) {
throw new Error(ENV_NOT_FOUND_KEY_AUTH_PROVIDERS);
}
if (process.env.VITE_ALLOWED_AUTH_PROVIDERS === '') {
throw new Error(ENV_EMPTY_AUTH_PROVIDERS);
}
const givenAuthProviders = process.env.VITE_ALLOWED_AUTH_PROVIDERS.split(
',',
).map((provider) => provider.toLocaleUpperCase());
const supportedAuthProviders = Object.values(AuthProvider).map(
(provider: string) => provider.toLocaleUpperCase(),
);
for (const givenAuthProvider of givenAuthProviders) {
if (!supportedAuthProviders.includes(givenAuthProvider)) {
throw new Error(ENV_NOT_SUPPORT_AUTH_PROVIDERS);
}
}
}

View File

@@ -6,7 +6,6 @@ module.exports = {
env: {
browser: true,
node: true,
jest: true,
},
parserOptions: {
sourceType: "module",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>

After

Width:  |  Height:  |  Size: 337 B

View File

@@ -4,6 +4,7 @@
@apply after:backface-hidden;
@apply selection:bg-accentDark;
@apply selection:text-accentContrast;
@apply overscroll-none;
}
:root {

View File

@@ -1,7 +1,7 @@
@mixin base-theme {
--font-sans: "Inter", sans-serif;
--font-mono: "Roboto Mono", monospace;
--font-icon: "Material Icons";
--font-sans: "Inter Variable", sans-serif;
--font-icon: "Material Symbols Rounded Variable";
--font-mono: "Roboto Mono Variable", monospace;
--font-size-tiny: calc(var(--font-size-body) - 0.062rem);
}

View File

@@ -4,6 +4,7 @@
"cancel": "Cancel",
"choose_file": "Choose a file",
"clear": "Clear",
"clear_history": "Clear All History",
"clear_all": "Clear all",
"close": "Close",
"connect": "Connect",
@@ -30,6 +31,7 @@
"open_workspace": "Open workspace",
"paste": "Paste",
"prettify": "Prettify",
"rename": "Rename",
"remove": "Remove",
"restore": "Restore",
"save": "Save",
@@ -131,6 +133,7 @@
"renamed": "Collection renamed",
"request_in_use": "Request in use",
"save_as": "Save as",
"save_to_collection": "Save to Collection",
"select": "Select a Collection",
"select_location": "Select location",
"select_team": "Select a team",
@@ -148,8 +151,14 @@
"remove_telemetry": "Are you sure you want to opt-out of Telemetry?",
"request_change": "Are you sure you want to discard current request, unsaved changes will be lost.",
"save_unsaved_tab": "Do you want to save changes made in this tab?",
"close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.",
"sync": "Would you like to restore your workspace from cloud? This will discard your local progress."
},
"context_menu": {
"set_environment_variable": "Set as variable",
"add_parameter": "Add to parameter",
"open_link_in_new_tab": "Open link in new tab"
},
"count": {
"header": "Header {count}",
"message": "Message {count}",
@@ -173,6 +182,7 @@
"folder": "Folder is empty",
"headers": "This request does not have any headers",
"history": "History is empty",
"history_suggestions": "History does not have any matching entries",
"invites": "Invite list is empty",
"members": "Team is empty",
"parameters": "This request does not have any parameters",
@@ -193,16 +203,23 @@
"created": "Environment created",
"deleted": "Environment deletion",
"edit": "Edit Environment",
"global": "Global",
"invalid_name": "Please provide a name for the environment",
"my_environments": "My Environments",
"name": "Name",
"nested_overflow": "nested environment variables are limited to 10 levels",
"new": "New Environment",
"no_environment": "No environment",
"no_environment_description": "No environments were selected. Choose what to do with the following variables.",
"replace_with_variable": "Replace with variable",
"scope": "Scope",
"select": "Select environment",
"set_as_environment": "Set as environment",
"team_environments": "Team Environments",
"title": "Environments",
"updated": "Environment updated",
"value": "Value",
"variable": "Variable",
"variable_list": "Variable List"
},
"error": {
@@ -418,6 +435,7 @@
"payload": "Payload",
"query": "Query",
"raw_body": "Raw Request Body",
"rename": "Rename Request",
"renamed": "Request renamed",
"run": "Run",
"save": "Save",
@@ -582,6 +600,11 @@
"log": "Log",
"url": "URL"
},
"spotlight": {
"section": {
"user": "User"
}
},
"sse": {
"event_type": "Event type",
"log": "Log",
@@ -639,8 +662,11 @@
"tab": {
"authorization": "Authorization",
"body": "Body",
"close": "Close Tab",
"close_others": "Close other Tabs",
"collections": "Collections",
"documentation": "Documentation",
"duplicate": "Duplicate Tab",
"environments": "Environments",
"headers": "Headers",
"history": "History",

View File

@@ -19,7 +19,7 @@
"edit": "編輯",
"filter": "篩選回應",
"go_back": "返回",
"go_forward": "Go forward",
"go_forward": "向前",
"group_by": "分組方式",
"label": "標籤",
"learn_more": "瞭解更多",
@@ -117,37 +117,37 @@
"username": "使用者名稱"
},
"collection": {
"created": "合已建立",
"different_parent": "Cannot reorder collection with different parent",
"edit": "編輯合",
"invalid_name": "請提供有效的合名稱",
"invalid_root_move": "Collection already in the root",
"moved": "Moved Successfully",
"my_collections": "我的合",
"name": "我的新合",
"name_length_insufficient": "合名稱至少要有 3 個字元。",
"new": "建立合",
"order_changed": "Collection Order Updated",
"renamed": "合已重新命名",
"created": "合已建立",
"different_parent": "無法為父集合不同的集合重新排序",
"edit": "編輯合",
"invalid_name": "請提供有效的合名稱",
"invalid_root_move": "集合已在根目錄",
"moved": "移動成功",
"my_collections": "我的合",
"name": "我的新合",
"name_length_insufficient": "合名稱至少要有 3 個字元。",
"new": "建立合",
"order_changed": "集合順序已更新",
"renamed": "合已重新命名",
"request_in_use": "請求正在使用中",
"save_as": "另存為",
"select": "選擇一個合",
"select": "選擇一個合",
"select_location": "選擇位置",
"select_team": "選擇一個團隊",
"team_collections": "團隊合"
"team_collections": "團隊合"
},
"confirm": {
"exit_team": "您確定要離開此團隊嗎?",
"logout": "您確定要登出嗎?",
"remove_collection": "您確定要永久刪除該合嗎?",
"remove_collection": "您確定要永久刪除該合嗎?",
"remove_environment": "您確定要永久刪除該環境嗎?",
"remove_folder": "您確定要永久刪除該資料夾嗎?",
"remove_history": "您確定要永久刪除全部歷史記錄嗎?",
"remove_request": "您確定要永久刪除該請求嗎?",
"remove_team": "您確定要刪除該團隊嗎?",
"remove_telemetry": "您確定要退出遙測服務嗎?",
"request_change": "您確定要捨棄當前請求嗎?未儲存的變更將遺失。",
"save_unsaved_tab": "Do you want to save changes made in this tab?",
"request_change": "您確定要捨棄目前的請求嗎?未儲存的變更將遺失。",
"save_unsaved_tab": "您要儲存在此分頁做出的改動嗎?",
"sync": "您想從雲端恢復您的工作區嗎?這將丟棄您的本地進度。"
},
"count": {
@@ -160,13 +160,13 @@
},
"documentation": {
"generate": "產生文件",
"generate_message": "匯入 Hoppscotch 合以隨時隨地產生 API 文件。"
"generate_message": "匯入 Hoppscotch 合以隨時隨地產生 API 文件。"
},
"empty": {
"authorization": "該請求沒有使用任何授權",
"body": "該請求沒有任何請求主體",
"collection": "合為空",
"collections": "合為空",
"collection": "合為空",
"collections": "合為空",
"documentation": "連線到 GraphQL 端點以檢視文件",
"endpoint": "端點不能留空",
"environments": "環境為空",
@@ -209,7 +209,7 @@
"browser_support_sse": "此瀏覽器似乎不支援 SSE。",
"check_console_details": "檢查控制台日誌以獲悉詳情",
"curl_invalid_format": "cURL 格式不正確",
"danger_zone": "Danger zone",
"danger_zone": "危險地帶",
"delete_account": "您的帳號目前為這些團隊的擁有者:",
"delete_account_description": "您在刪除帳號前必須先將您自己從團隊中移除、轉移擁有權,或是刪除團隊。",
"empty_req_name": "空請求名稱",
@@ -277,38 +277,38 @@
"tests": "編寫測試指令碼以自動除錯。"
},
"hide": {
"collection": "隱藏合面板",
"collection": "隱藏合面板",
"more": "隱藏更多",
"preview": "隱藏預覽",
"sidebar": "隱藏側邊欄"
},
"import": {
"collections": "匯入合",
"collections": "匯入合",
"curl": "匯入 cURL",
"failed": "匯入失敗",
"from_gist": "從 Gist 匯入",
"from_gist_description": "從 Gist 網址匯入",
"from_insomnia": "從 Insomnia 匯入",
"from_insomnia_description": "從 Insomnia 合匯入",
"from_insomnia_description": "從 Insomnia 合匯入",
"from_json": "從 Hoppscotch 匯入",
"from_json_description": "從 Hoppscotch 合檔匯入",
"from_my_collections": "從我的合匯入",
"from_my_collections_description": "從我的合檔匯入",
"from_json_description": "從 Hoppscotch 合檔匯入",
"from_my_collections": "從我的合匯入",
"from_my_collections_description": "從我的合檔匯入",
"from_openapi": "從 OpenAPI 匯入",
"from_openapi_description": "從 OpenAPI 規格檔 (YML/JSON) 匯入",
"from_postman": "從 Postman 匯入",
"from_postman_description": "從 Postman 合匯入",
"from_postman_description": "從 Postman 合匯入",
"from_url": "從網址匯入",
"gist_url": "輸入 Gist 網址",
"import_from_url_invalid_fetch": "無法從網址取得資料",
"import_from_url_invalid_file_format": "匯入合時發生錯誤",
"import_from_url_invalid_file_format": "匯入合時發生錯誤",
"import_from_url_invalid_type": "不支援此類型。可接受的值為 'hoppscotch'、'openapi'、'postman'、'insomnia'",
"import_from_url_success": "已匯入合",
"json_description": "從 Hoppscotch 合 JSON 檔匯入合",
"import_from_url_success": "已匯入合",
"json_description": "從 Hoppscotch 合 JSON 檔匯入合",
"title": "匯入"
},
"layout": {
"collapse_collection": "隱藏或顯示合",
"collapse_collection": "隱藏或顯示合",
"collapse_sidebar": "隱藏或顯示側邊欄",
"column": "垂直版面",
"name": "配置",
@@ -316,8 +316,8 @@
"zen_mode": "專注模式"
},
"modal": {
"close_unsaved_tab": "You have unsaved changes",
"collections": "合",
"close_unsaved_tab": "您有未儲存的改動",
"collections": "合",
"confirm": "確認",
"edit_request": "編輯請求",
"import_export": "匯入/匯出"
@@ -374,9 +374,9 @@
"email_verification_mail": "已將驗證信寄送至您的電子郵件地址。請點擊信中連結以驗證您的電子郵件地址。",
"no_permission": "您沒有權限執行此操作。",
"owner": "擁有者",
"owner_description": "擁有者可以新增、編輯和刪除請求、合和團隊成員。",
"owner_description": "擁有者可以新增、編輯和刪除請求、合和團隊成員。",
"roles": "角色",
"roles_description": "角色用來控制對共用合的存取權。",
"roles_description": "角色用來控制對共用合的存取權。",
"updated": "已更新個人檔案",
"viewer": "檢視者",
"viewer_description": "檢視者只能檢視和使用請求。"
@@ -396,8 +396,8 @@
"text": "文字"
},
"copy_link": "複製連結",
"different_collection": "Cannot reorder requests from different collections",
"duplicated": "Request duplicated",
"different_collection": "無法重新排列來自不同集合的請求",
"duplicated": "已複製請求",
"duration": "持續時間",
"enter_curl": "輸入 cURL",
"generate_code": "產生程式碼",
@@ -405,10 +405,10 @@
"header_list": "請求標頭列表",
"invalid_name": "請提供請求名稱",
"method": "方法",
"moved": "Request moved",
"moved": "已移動請求",
"name": "請求名稱",
"new": "新請求",
"order_changed": "Request Order Updated",
"order_changed": "已更新請求順序",
"override": "覆寫",
"override_help": "在標頭設置 <kbd>Content-Type</kbd>",
"overriden": "已覆寫",
@@ -432,7 +432,7 @@
"view_my_links": "檢視我的連結"
},
"response": {
"audio": "Audio",
"audio": "音訊",
"body": "回應本體",
"filter_response_body": "篩選 JSON 回應本體 (使用 JSONPath 語法)",
"headers": "回應標頭",
@@ -446,7 +446,7 @@
"status": "狀態",
"time": "時間",
"title": "回應",
"video": "Video",
"video": "視訊",
"waiting_for_connection": "等待連線",
"xml": "XML"
},
@@ -494,7 +494,7 @@
"short_codes_description": "我們為您打造的快捷碼。",
"sidebar_on_left": "左側邊欄",
"sync": "同步",
"sync_collections": "合",
"sync_collections": "合",
"sync_description": "這些設定會同步到雲端。",
"sync_environments": "環境",
"sync_history": "歷史",
@@ -551,7 +551,7 @@
"previous_method": "選擇上一個方法",
"put_method": "選擇 PUT 方法",
"reset_request": "重置請求",
"save_to_collections": "儲存到合",
"save_to_collections": "儲存到合",
"send_request": "傳送請求",
"title": "請求"
},
@@ -570,7 +570,7 @@
},
"show": {
"code": "顯示程式碼",
"collection": "顯示合面板",
"collection": "顯示合面板",
"more": "顯示更多",
"sidebar": "顯示側邊欄"
},
@@ -639,9 +639,9 @@
"tab": {
"authorization": "授權",
"body": "請求本體",
"collections": "合",
"collections": "合",
"documentation": "幫助文件",
"environments": "Environments",
"environments": "環境",
"headers": "請求標頭",
"history": "歷史記錄",
"mqtt": "MQTT",
@@ -666,7 +666,7 @@
"email_do_not_match": "電子信箱與您的帳號資料不一致。請聯絡您的團隊擁有者。",
"exit": "退出團隊",
"exit_disabled": "團隊擁有者無法退出團隊",
"invalid_coll_id": "Invalid collection ID",
"invalid_coll_id": "集合 ID 無效",
"invalid_email_format": "電子信箱格式無效",
"invalid_id": "團隊 ID 無效。請聯絡您的團隊擁有者。",
"invalid_invite_link": "邀請連結無效",
@@ -690,21 +690,21 @@
"member_removed": "使用者已移除",
"member_role_updated": "使用者角色已更新",
"members": "成員",
"more_members": "+{count} more",
"more_members": "還有 {count} ",
"name_length_insufficient": "團隊名稱至少為 6 個字元",
"name_updated": "團隊名稱已更新",
"new": "新團隊",
"new_created": "已建立新團隊",
"new_name": "我的新團隊",
"no_access": "您沒有編輯合的許可權",
"no_access": "您沒有編輯合的許可權",
"no_invite_found": "未找到邀請。請聯絡您的團隊擁有者。",
"no_request_found": "Request not found.",
"no_request_found": "找不到請求。",
"not_found": "找不到團隊。請聯絡您的團隊擁有者。",
"not_valid_viewer": "您不是一個有效的檢視者。請聯絡您的團隊擁有者。",
"parent_coll_move": "Cannot move collection to a child collection",
"parent_coll_move": "無法將集合移動至子集合",
"pending_invites": "待定邀請",
"permissions": "許可權",
"same_target_destination": "Same target and destination",
"same_target_destination": "目標和目的地相同",
"saved": "團隊已儲存",
"select_a_team": "選擇團隊",
"title": "團隊",
@@ -734,9 +734,9 @@
"url": "網址"
},
"workspace": {
"change": "Change workspace",
"personal": "My Workspace",
"team": "Team Workspace",
"title": "Workspaces"
"change": "切換工作區",
"personal": "我的工作區",
"team": "團隊工作區",
"title": "工作區"
}
}

View File

@@ -1,9 +1,11 @@
{
"name": "@hoppscotch/common",
"private": true,
"version": "2023.4.6",
"version": "2023.4.8",
"scripts": {
"dev": "pnpm exec npm-run-all -p -l dev:*",
"test": "vitest --run",
"test:watch": "vitest",
"dev:vite": "vite",
"dev:gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml --watch dotenv_config_path=\"../../.env\"",
"lint": "eslint src --ext .ts,.js,.vue --ignore-path .gitignore .",
@@ -13,6 +15,7 @@
"preview": "vite preview",
"gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml dotenv_config_path=\"../../.env\"",
"postinstall": "pnpm run gql-codegen",
"do-test": "pnpm run test",
"do-lint": "pnpm run prod-lint",
"do-typecheck": "pnpm run lint",
"do-lintfix": "pnpm run lintfix"
@@ -30,6 +33,9 @@
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.1.0",
"@codemirror/view": "^6.0.2",
"@fontsource-variable/inter": "^5.0.5",
"@fontsource-variable/material-symbols-rounded": "^5.0.5",
"@fontsource-variable/roboto-mono": "^5.0.6",
"@hoppscotch/codemirror-lang-graphql": "workspace:^",
"@hoppscotch/data": "workspace:^",
"@hoppscotch/js-sandbox": "workspace:^",
@@ -43,11 +49,12 @@
"@urql/exchange-auth": "^0.1.7",
"@urql/exchange-graphcache": "^4.4.3",
"@vitejs/plugin-legacy": "^2.3.0",
"@vueuse/core": "^8.7.5",
"@vueuse/core": "^8.9.4",
"@vueuse/head": "^0.7.9",
"acorn-walk": "^8.2.0",
"axios": "^0.21.4",
"buffer": "^6.0.3",
"dioc": "workspace:^",
"esprima": "^4.0.1",
"events": "^3.3.0",
"fp-ts": "^2.12.1",
@@ -63,6 +70,7 @@
"jsonpath-plus": "^7.0.0",
"lodash-es": "^4.17.21",
"lossless-json": "^2.0.8",
"minisearch": "^6.1.0",
"nprogress": "^0.2.0",
"paho-mqtt": "^1.1.0",
"path": "^0.12.7",
@@ -84,7 +92,6 @@
"util": "^0.12.4",
"uuid": "^8.3.2",
"vue": "^3.2.25",
"vue-github-button": "^3.0.3",
"vue-i18n": "^9.2.2",
"vue-pdf-embed": "^1.1.4",
"vue-router": "^4.0.16",
@@ -106,8 +113,9 @@
"@graphql-codegen/typescript-urql-graphcache": "^2.3.1",
"@graphql-codegen/urql-introspection": "^2.2.0",
"@graphql-typed-document-node/core": "^3.1.1",
"@iconify-json/lucide": "^1.1.40",
"@iconify-json/lucide": "^1.1.109",
"@intlify/vite-plugin-vue-i18n": "^7.0.0",
"@relmify/jest-fp-ts": "^2.1.1",
"@rushstack/eslint-patch": "^1.1.4",
"@types/js-yaml": "^4.0.5",
"@types/lodash-es": "^4.17.6",
@@ -134,18 +142,19 @@
"rollup-plugin-polyfill-node": "^0.10.1",
"sass": "^1.53.0",
"typescript": "^4.5.4",
"unplugin-fonts": "^1.0.3",
"unplugin-icons": "^0.14.9",
"unplugin-vue-components": "^0.21.0",
"vite": "^3.1.4",
"vite-plugin-checker": "^0.5.1",
"vite-plugin-fonts": "^0.6.0",
"vite-plugin-html-config": "^1.0.10",
"vite-plugin-inspect": "^0.7.4",
"vite-plugin-pages": "^0.26.0",
"vite-plugin-pages-sitemap": "^1.4.0",
"vite-plugin-pages-sitemap": "^1.4.5",
"vite-plugin-pwa": "^0.13.1",
"vite-plugin-vue-layouts": "^0.7.0",
"vite-plugin-windicss": "^1.8.8",
"vitest": "^0.32.2",
"vue-tsc": "^0.38.2",
"windicss": "^3.5.6"
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="156" height="32" fill="none"><rect width="156" height="32" fill="#6366f1" rx="4"/><text xmlns="http://www.w3.org/2000/svg" x="50%" y="50%" fill="#fff" dominant-baseline="central" font-family="Helvetica,sans-serif" font-size="12" font-weight="bold" text-anchor="middle" text-rendering="geometricPrecision">▶ Run in Hoppscotch</text></svg>

After

Width:  |  Height:  |  Size: 389 B

View File

@@ -1,12 +1,204 @@
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
import "@vue/runtime-core"
export {}
declare module '@vue/runtime-core' {
declare module "@vue/runtime-core" {
export interface GlobalComponents {
AppActionHandler: typeof import("./components/app/ActionHandler.vue")["default"]
AppAnnouncement: typeof import("./components/app/Announcement.vue")["default"]
AppDeveloperOptions: typeof import("./components/app/DeveloperOptions.vue")["default"]
AppFooter: typeof import("./components/app/Footer.vue")["default"]
AppGitHubStarButton: typeof import("./components/app/GitHubStarButton.vue")["default"]
AppHeader: typeof import("./components/app/Header.vue")["default"]
AppInterceptor: typeof import("./components/app/Interceptor.vue")["default"]
AppLogo: typeof import("./components/app/Logo.vue")["default"]
AppOptions: typeof import("./components/app/Options.vue")["default"]
AppPaneLayout: typeof import("./components/app/PaneLayout.vue")["default"]
AppShare: typeof import("./components/app/Share.vue")["default"]
AppShortcuts: typeof import("./components/app/Shortcuts.vue")["default"]
AppShortcutsEntry: typeof import("./components/app/ShortcutsEntry.vue")["default"]
AppShortcutsPrompt: typeof import("./components/app/ShortcutsPrompt.vue")["default"]
AppSidenav: typeof import("./components/app/Sidenav.vue")["default"]
AppSpotlight: typeof import("./components/app/spotlight/index.vue")["default"]
AppSpotlightEntry: typeof import("./components/app/spotlight/Entry.vue")["default"]
AppSpotlightEntryGQLHistory: typeof import("./components/app/spotlight/entry/GQLHistory.vue")["default"]
AppSpotlightEntryRESTHistory: typeof import("./components/app/spotlight/entry/RESTHistory.vue")["default"]
AppSupport: typeof import("./components/app/Support.vue")["default"]
ButtonPrimary: typeof import("./../../hoppscotch-ui/src/components/button/Primary.vue")["default"]
ButtonSecondary: typeof import("./../../hoppscotch-ui/src/components/button/Secondary.vue")["default"]
Collections: typeof import("./components/collections/index.vue")["default"]
CollectionsAdd: typeof import("./components/collections/Add.vue")["default"]
CollectionsAddFolder: typeof import("./components/collections/AddFolder.vue")["default"]
CollectionsAddRequest: typeof import("./components/collections/AddRequest.vue")["default"]
CollectionsCollection: typeof import("./components/collections/Collection.vue")["default"]
CollectionsEdit: typeof import("./components/collections/Edit.vue")["default"]
CollectionsEditFolder: typeof import("./components/collections/EditFolder.vue")["default"]
CollectionsEditRequest: typeof import("./components/collections/EditRequest.vue")["default"]
CollectionsGraphql: typeof import("./components/collections/graphql/index.vue")["default"]
CollectionsGraphqlAdd: typeof import("./components/collections/graphql/Add.vue")["default"]
CollectionsGraphqlAddFolder: typeof import("./components/collections/graphql/AddFolder.vue")["default"]
CollectionsGraphqlAddRequest: typeof import("./components/collections/graphql/AddRequest.vue")["default"]
CollectionsGraphqlCollection: typeof import("./components/collections/graphql/Collection.vue")["default"]
CollectionsGraphqlEdit: typeof import("./components/collections/graphql/Edit.vue")["default"]
CollectionsGraphqlEditFolder: typeof import("./components/collections/graphql/EditFolder.vue")["default"]
CollectionsGraphqlEditRequest: typeof import("./components/collections/graphql/EditRequest.vue")["default"]
CollectionsGraphqlFolder: typeof import("./components/collections/graphql/Folder.vue")["default"]
CollectionsGraphqlImportExport: typeof import("./components/collections/graphql/ImportExport.vue")["default"]
CollectionsGraphqlRequest: typeof import("./components/collections/graphql/Request.vue")["default"]
CollectionsImportExport: typeof import("./components/collections/ImportExport.vue")["default"]
CollectionsMyCollections: typeof import("./components/collections/MyCollections.vue")["default"]
CollectionsRequest: typeof import("./components/collections/Request.vue")["default"]
CollectionsSaveRequest: typeof import("./components/collections/SaveRequest.vue")["default"]
CollectionsTeamCollections: typeof import("./components/collections/TeamCollections.vue")["default"]
Environments: typeof import("./components/environments/index.vue")["default"]
EnvironmentsImportExport: typeof import("./components/environments/ImportExport.vue")["default"]
EnvironmentsMy: typeof import("./components/environments/my/index.vue")["default"]
EnvironmentsMyDetails: typeof import("./components/environments/my/Details.vue")["default"]
EnvironmentsMyEnvironment: typeof import("./components/environments/my/Environment.vue")["default"]
EnvironmentsSelector: typeof import("./components/environments/Selector.vue")["default"]
EnvironmentsTeams: typeof import("./components/environments/teams/index.vue")["default"]
EnvironmentsTeamsDetails: typeof import("./components/environments/teams/Details.vue")["default"]
EnvironmentsTeamsEnvironment: typeof import("./components/environments/teams/Environment.vue")["default"]
FirebaseLogin: typeof import("./components/firebase/Login.vue")["default"]
FirebaseLogout: typeof import("./components/firebase/Logout.vue")["default"]
GraphqlAuthorization: typeof import("./components/graphql/Authorization.vue")["default"]
GraphqlField: typeof import("./components/graphql/Field.vue")["default"]
GraphqlRequest: typeof import("./components/graphql/Request.vue")["default"]
GraphqlRequestOptions: typeof import("./components/graphql/RequestOptions.vue")["default"]
GraphqlResponse: typeof import("./components/graphql/Response.vue")["default"]
GraphqlSidebar: typeof import("./components/graphql/Sidebar.vue")["default"]
GraphqlType: typeof import("./components/graphql/Type.vue")["default"]
GraphqlTypeLink: typeof import("./components/graphql/TypeLink.vue")["default"]
History: typeof import("./components/history/index.vue")["default"]
HistoryGraphqlCard: typeof import("./components/history/graphql/Card.vue")["default"]
HistoryRestCard: typeof import("./components/history/rest/Card.vue")["default"]
HoppButtonPrimary: typeof import("@hoppscotch/ui")["HoppButtonPrimary"]
HoppButtonSecondary: typeof import("@hoppscotch/ui")["HoppButtonSecondary"]
HoppSmartAnchor: typeof import("@hoppscotch/ui")["HoppSmartAnchor"]
HoppSmartAutoComplete: typeof import("@hoppscotch/ui")["HoppSmartAutoComplete"]
HoppSmartCheckbox: typeof import("@hoppscotch/ui")["HoppSmartCheckbox"]
HoppSmartConfirmModal: typeof import("@hoppscotch/ui")["HoppSmartConfirmModal"]
HoppSmartExpand: typeof import("@hoppscotch/ui")["HoppSmartExpand"]
HoppSmartFileChip: typeof import("@hoppscotch/ui")["HoppSmartFileChip"]
HoppSmartInput: typeof import("@hoppscotch/ui")["HoppSmartInput"]
HoppSmartIntersection: typeof import("@hoppscotch/ui")["HoppSmartIntersection"]
HoppSmartItem: typeof import("@hoppscotch/ui")["HoppSmartItem"]
HoppSmartLink: typeof import("@hoppscotch/ui")["HoppSmartLink"]
HoppSmartModal: typeof import("@hoppscotch/ui")["HoppSmartModal"]
HoppSmartPicture: typeof import("@hoppscotch/ui")["HoppSmartPicture"]
HoppSmartPlaceholder: typeof import("@hoppscotch/ui")["HoppSmartPlaceholder"]
HoppSmartProgressRing: typeof import("@hoppscotch/ui")["HoppSmartProgressRing"]
HoppSmartRadioGroup: typeof import("@hoppscotch/ui")["HoppSmartRadioGroup"]
HoppSmartSlideOver: typeof import("@hoppscotch/ui")["HoppSmartSlideOver"]
HoppSmartSpinner: typeof import("@hoppscotch/ui")["HoppSmartSpinner"]
HoppSmartTab: typeof import("@hoppscotch/ui")["HoppSmartTab"]
HoppSmartTabs: typeof import("@hoppscotch/ui")["HoppSmartTabs"]
HoppSmartToggle: typeof import("@hoppscotch/ui")["HoppSmartToggle"]
HoppSmartWindow: typeof import("@hoppscotch/ui")["HoppSmartWindow"]
HoppSmartWindows: typeof import("@hoppscotch/ui")["HoppSmartWindows"]
HttpAuthorization: typeof import("./components/http/Authorization.vue")["default"]
HttpAuthorizationApiKey: typeof import("./components/http/authorization/ApiKey.vue")["default"]
HttpAuthorizationBasic: typeof import("./components/http/authorization/Basic.vue")["default"]
HttpBody: typeof import("./components/http/Body.vue")["default"]
HttpBodyParameters: typeof import("./components/http/BodyParameters.vue")["default"]
HttpCodegenModal: typeof import("./components/http/CodegenModal.vue")["default"]
HttpHeaders: typeof import("./components/http/Headers.vue")["default"]
HttpImportCurl: typeof import("./components/http/ImportCurl.vue")["default"]
HttpOAuth2Authorization: typeof import("./components/http/OAuth2Authorization.vue")["default"]
HttpParameters: typeof import("./components/http/Parameters.vue")["default"]
HttpPreRequestScript: typeof import("./components/http/PreRequestScript.vue")["default"]
HttpRawBody: typeof import("./components/http/RawBody.vue")["default"]
HttpReqChangeConfirmModal: typeof import("./components/http/ReqChangeConfirmModal.vue")["default"]
HttpRequest: typeof import("./components/http/Request.vue")["default"]
HttpRequestOptions: typeof import("./components/http/RequestOptions.vue")["default"]
HttpRequestTab: typeof import("./components/http/RequestTab.vue")["default"]
HttpResponse: typeof import("./components/http/Response.vue")["default"]
HttpResponseMeta: typeof import("./components/http/ResponseMeta.vue")["default"]
HttpSidebar: typeof import("./components/http/Sidebar.vue")["default"]
HttpTestResult: typeof import("./components/http/TestResult.vue")["default"]
HttpTestResultEntry: typeof import("./components/http/TestResultEntry.vue")["default"]
HttpTestResultEnv: typeof import("./components/http/TestResultEnv.vue")["default"]
HttpTestResultReport: typeof import("./components/http/TestResultReport.vue")["default"]
HttpTests: typeof import("./components/http/Tests.vue")["default"]
HttpURLEncodedParams: typeof import("./components/http/URLEncodedParams.vue")["default"]
IconLucideAlertTriangle: typeof import("~icons/lucide/alert-triangle")["default"]
IconLucideArrowLeft: typeof import("~icons/lucide/arrow-left")["default"]
IconLucideCheckCircle: typeof import("~icons/lucide/check-circle")["default"]
IconLucideChevronRight: typeof import("~icons/lucide/chevron-right")["default"]
IconLucideGlobe: typeof import("~icons/lucide/globe")["default"]
IconLucideHelpCircle: typeof import("~icons/lucide/help-circle")["default"]
IconLucideInbox: typeof import("~icons/lucide/inbox")["default"]
IconLucideInfo: typeof import("~icons/lucide/info")["default"]
IconLucideLayers: typeof import("~icons/lucide/layers")["default"]
IconLucideListEnd: typeof import("~icons/lucide/list-end")["default"]
IconLucideMinus: typeof import("~icons/lucide/minus")["default"]
IconLucideSearch: typeof import("~icons/lucide/search")["default"]
IconLucideUsers: typeof import("~icons/lucide/users")["default"]
IconLucideVerified: typeof import("~icons/lucide/verified")["default"]
LensesHeadersRenderer: typeof import("./components/lenses/HeadersRenderer.vue")["default"]
LensesHeadersRendererEntry: typeof import("./components/lenses/HeadersRendererEntry.vue")["default"]
LensesRenderersAudioLensRenderer: typeof import("./components/lenses/renderers/AudioLensRenderer.vue")["default"]
LensesRenderersHTMLLensRenderer: typeof import("./components/lenses/renderers/HTMLLensRenderer.vue")["default"]
LensesRenderersImageLensRenderer: typeof import("./components/lenses/renderers/ImageLensRenderer.vue")["default"]
LensesRenderersJSONLensRenderer: typeof import("./components/lenses/renderers/JSONLensRenderer.vue")["default"]
LensesRenderersPDFLensRenderer: typeof import("./components/lenses/renderers/PDFLensRenderer.vue")["default"]
LensesRenderersRawLensRenderer: typeof import("./components/lenses/renderers/RawLensRenderer.vue")["default"]
LensesRenderersVideoLensRenderer: typeof import("./components/lenses/renderers/VideoLensRenderer.vue")["default"]
LensesRenderersXMLLensRenderer: typeof import("./components/lenses/renderers/XMLLensRenderer.vue")["default"]
LensesResponseBodyRenderer: typeof import("./components/lenses/ResponseBodyRenderer.vue")["default"]
ProfileShortcode: typeof import("./components/profile/Shortcode.vue")["default"]
ProfileShortcodes: typeof import("./components/profile/Shortcodes.vue")["default"]
ProfileUserDelete: typeof import("./components/profile/UserDelete.vue")["default"]
RealtimeCommunication: typeof import("./components/realtime/Communication.vue")["default"]
RealtimeConnectionConfig: typeof import("./components/realtime/ConnectionConfig.vue")["default"]
RealtimeLog: typeof import("./components/realtime/Log.vue")["default"]
RealtimeLogEntry: typeof import("./components/realtime/LogEntry.vue")["default"]
RealtimeSubscription: typeof import("./components/realtime/Subscription.vue")["default"]
SmartAccentModePicker: typeof import("./components/smart/AccentModePicker.vue")["default"]
SmartAnchor: typeof import("./../../hoppscotch-ui/src/components/smart/Anchor.vue")["default"]
SmartAutoComplete: typeof import("./../../hoppscotch-ui/src/components/smart/AutoComplete.vue")["default"]
SmartChangeLanguage: typeof import("./components/smart/ChangeLanguage.vue")["default"]
SmartCheckbox: typeof import("./../../hoppscotch-ui/src/components/smart/Checkbox.vue")["default"]
SmartColorModePicker: typeof import("./components/smart/ColorModePicker.vue")["default"]
SmartConfirmModal: typeof import("./../../hoppscotch-ui/src/components/smart/ConfirmModal.vue")["default"]
SmartEnvInput: typeof import("./components/smart/EnvInput.vue")["default"]
SmartExpand: typeof import("./../../hoppscotch-ui/src/components/smart/Expand.vue")["default"]
SmartFileChip: typeof import("./../../hoppscotch-ui/src/components/smart/FileChip.vue")["default"]
SmartFontSizePicker: typeof import("./components/smart/FontSizePicker.vue")["default"]
SmartInput: typeof import("./../../hoppscotch-ui/src/components/smart/Input.vue")["default"]
SmartIntersection: typeof import("./../../hoppscotch-ui/src/components/smart/Intersection.vue")["default"]
SmartItem: typeof import("./../../hoppscotch-ui/src/components/smart/Item.vue")["default"]
SmartLink: typeof import("./../../hoppscotch-ui/src/components/smart/Link.vue")["default"]
SmartModal: typeof import("./../../hoppscotch-ui/src/components/smart/Modal.vue")["default"]
SmartPicture: typeof import("./../../hoppscotch-ui/src/components/smart/Picture.vue")["default"]
SmartPlaceholder: typeof import("./../../hoppscotch-ui/src/components/smart/Placeholder.vue")["default"]
SmartProgressRing: typeof import("./../../hoppscotch-ui/src/components/smart/ProgressRing.vue")["default"]
SmartRadio: typeof import("./../../hoppscotch-ui/src/components/smart/Radio.vue")["default"]
SmartRadioGroup: typeof import("./../../hoppscotch-ui/src/components/smart/RadioGroup.vue")["default"]
SmartSlideOver: typeof import("./../../hoppscotch-ui/src/components/smart/SlideOver.vue")["default"]
SmartSpinner: typeof import("./../../hoppscotch-ui/src/components/smart/Spinner.vue")["default"]
SmartTab: typeof import("./../../hoppscotch-ui/src/components/smart/Tab.vue")["default"]
SmartTabs: typeof import("./../../hoppscotch-ui/src/components/smart/Tabs.vue")["default"]
SmartToggle: typeof import("./../../hoppscotch-ui/src/components/smart/Toggle.vue")["default"]
SmartTree: typeof import("./components/smart/Tree.vue")["default"]
SmartTreeBranch: typeof import("./components/smart/TreeBranch.vue")["default"]
SmartWindow: typeof import("./../../hoppscotch-ui/src/components/smart/Window.vue")["default"]
SmartWindows: typeof import("./../../hoppscotch-ui/src/components/smart/Windows.vue")["default"]
TabPrimary: typeof import("./components/tab/Primary.vue")["default"]
TabSecondary: typeof import("./components/tab/Secondary.vue")["default"]
Teams: typeof import("./components/teams/index.vue")["default"]
TeamsAdd: typeof import("./components/teams/Add.vue")["default"]
TeamsEdit: typeof import("./components/teams/Edit.vue")["default"]
TeamsInvite: typeof import("./components/teams/Invite.vue")["default"]
TeamsMemberStack: typeof import("./components/teams/MemberStack.vue")["default"]
TeamsModal: typeof import("./components/teams/Modal.vue")["default"]
TeamsTeam: typeof import("./components/teams/Team.vue")["default"]
Tippy: typeof import("vue-tippy")["Tippy"]
WorkspaceCurrent: typeof import("./components/workspace/Current.vue")["default"]
WorkspaceSelector: typeof import("./components/workspace/Selector.vue")["default"]
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
@@ -82,16 +274,19 @@ declare module '@vue/runtime-core' {
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
HoppSmartExpand: typeof import('@hoppscotch/ui')['HoppSmartExpand']
HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip']
HoppSmartIntersection: typeof import('@hoppscotch/ui')['HoppSmartIntersection']
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink']
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture']
HoppSmartPlaceholder: typeof import('@hoppscotch/ui')['HoppSmartPlaceholder']
HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing']
HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup']
HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle']
HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow']
HoppSmartWindows: typeof import('@hoppscotch/ui')['HoppSmartWindows']
HttpAuthorization: typeof import('./components/http/Authorization.vue')['default']
@@ -113,6 +308,7 @@ declare module '@vue/runtime-core' {
HttpResponse: typeof import('./components/http/Response.vue')['default']
HttpResponseMeta: typeof import('./components/http/ResponseMeta.vue')['default']
HttpSidebar: typeof import('./components/http/Sidebar.vue')['default']
HttpTabHead: typeof import('./components/http/TabHead.vue')['default']
HttpTestResult: typeof import('./components/http/TestResult.vue')['default']
HttpTestResultEntry: typeof import('./components/http/TestResultEntry.vue')['default']
HttpTestResultEnv: typeof import('./components/http/TestResultEnv.vue')['default']
@@ -132,6 +328,7 @@ declare module '@vue/runtime-core' {
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default']
LensesRenderersAudioLensRenderer: typeof import('./components/lenses/renderers/AudioLensRenderer.vue')['default']
@@ -192,5 +389,4 @@ declare module '@vue/runtime-core' {
WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
}
}

View File

@@ -0,0 +1,76 @@
<template>
<div
ref="contextMenuRef"
class="fixed bg-popover shadow-lg transform translate-y-8 border border-dividerDark p-2 rounded"
:style="`top: ${position.top}px; left: ${position.left}px; z-index: 1000;`"
>
<div v-if="contextMenuOptions" class="flex flex-col">
<div
v-for="option in contextMenuOptions"
:key="option.id"
class="flex flex-col space-y-2"
>
<HoppSmartItem
v-if="option.text.type === 'text' && option.text"
:icon="option.icon"
:label="option.text.text"
@click="handleClick(option)"
/>
<component
:is="option.text.component"
v-else-if="option.text.type === 'custom'"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onClickOutside } from "@vueuse/core"
import { useService } from "dioc/vue"
import { ref, watch } from "vue"
import { ContextMenuResult, ContextMenuService } from "~/services/context-menu"
import { EnvironmentMenuService } from "~/services/context-menu/menu/environment.menu"
import { ParameterMenuService } from "~/services/context-menu/menu/parameter.menu"
import { URLMenuService } from "~/services/context-menu/menu/url.menu"
const props = defineProps<{
show: boolean
position: { top: number; left: number }
text: string | null
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const contextMenuRef = ref<any | null>(null)
const contextMenuOptions = ref<ContextMenuResult[]>([])
onClickOutside(contextMenuRef, () => {
emit("hide-modal")
})
const contextMenuService = useService(ContextMenuService)
useService(EnvironmentMenuService)
useService(ParameterMenuService)
useService(URLMenuService)
const handleClick = (option: { action: () => void }) => {
option.action()
emit("hide-modal")
}
watch(
() => [props.show, props.text],
(val) => {
if (val && props.text) {
const options = contextMenuService.getMenuFor(props.text)
contextMenuOptions.value = options
}
},
{ immediate: true }
)
</script>

View File

@@ -152,7 +152,7 @@
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${t(
'app.shortcuts'
)} <kbd>${getSpecialKey()}</kbd><kbd>K</kbd>`"
)} <kbd>${getSpecialKey()}</kbd><kbd>/</kbd>`"
:icon="IconZap"
@click="invokeAction('flyouts.keybinds.toggle')"
/>

View File

@@ -1,70 +0,0 @@
<template>
<div key="outputHash" class="flex flex-col flex-1 overflow-auto">
<div class="flex flex-col">
<AppPowerSearchEntry
v-for="(shortcut, shortcutIndex) in searchResults"
:key="`shortcut-${shortcutIndex}`"
:active="shortcutIndex === selectedEntry"
:shortcut="shortcut.item"
@action="emit('action', shortcut.item.action)"
@mouseover="selectedEntry = shortcutIndex"
/>
</div>
<div
v-if="searchResults.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
<span class="my-2 text-center">
{{ t("state.nothing_found") }} "{{ search }}"
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onUnmounted, onMounted } from "vue"
import Fuse from "fuse.js"
import { useArrowKeysNavigation } from "~/helpers/powerSearchNavigation"
import { HoppAction } from "~/helpers/actions"
import { useI18n } from "@composables/i18n"
const t = useI18n()
const props = defineProps<{
input: Record<string, any>[]
search: string
}>()
const emit = defineEmits<{
(e: "action", action: HoppAction): void
}>()
const options = {
keys: ["keys", "label", "action", "tags"],
}
const fuse = new Fuse(props.input, options)
const searchResults = computed(() => fuse.search(props.search))
const searchResultsItems = computed(() =>
searchResults.value.map((searchResult) => searchResult.item)
)
const emitSearchAction = (action: HoppAction) => emit("action", action)
const { bindArrowKeysListeners, unbindArrowKeysListeners, selectedEntry } =
useArrowKeysNavigation(searchResultsItems, {
onEnter: emitSearchAction,
stopPropagation: true,
})
onMounted(() => {
bindArrowKeysListeners()
})
onUnmounted(() => {
unbindArrowKeysListeners()
})
</script>

View File

@@ -15,16 +15,21 @@
:label="t('app.name')"
to="/"
/>
<!-- <AppGitHubStarButton class="mt-1.5 transition" /> -->
</div>
<div class="inline-flex items-center space-x-2">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${t('app.search')} <kbd>/</kbd>`"
:icon="IconSearch"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
<div class="inline-flex items-center justify-center flex-1 space-x-2">
<button
class="flex flex-1 items-center justify-between px-2 py-1 bg-primaryDark transition text-secondaryLight cursor-text rounded border border-dividerDark max-w-xs hover:border-dividerDark hover:bg-primaryLight hover:text-secondary focus-visible:border-dividerDark focus-visible:bg-primaryLight focus-visible:text-secondary"
@click="invokeAction('modals.search.toggle')"
/>
>
<span class="inline-flex flex-1 items-center">
<icon-lucide-search class="mr-2 svg-icons" />
{{ t("app.search") }}
</span>
<span class="flex">
<kbd class="shortcut-key">{{ getPlatformSpecialKey() }}</kbd>
<kbd class="shortcut-key">K</kbd>
</span>
</button>
<HoppButtonSecondary
v-if="showInstallButton"
v-tippy="{ theme: 'tooltip' }"
@@ -42,6 +47,8 @@
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="invokeAction('modals.support.toggle')"
/>
</div>
<div class="inline-flex items-center justify-end flex-1 space-x-2">
<div
v-if="currentUser === null"
class="inline-flex items-center space-x-2"
@@ -236,17 +243,17 @@ import IconDownload from "~icons/lucide/download"
import IconUploadCloud from "~icons/lucide/upload-cloud"
import IconUserPlus from "~icons/lucide/user-plus"
import IconLifeBuoy from "~icons/lucide/life-buoy"
import IconSearch from "~icons/lucide/search"
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
import { platform } from "~/platform"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { invokeAction } from "@helpers/actions"
import { defineActionHandler, invokeAction } from "@helpers/actions"
import { workspaceStatus$, updateWorkspaceTeamName } from "~/newstore/workspace"
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { onLoggedIn } from "~/composables/auth"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { getPlatformSpecialKey } from "~/helpers/platformutils"
const t = useI18n()
@@ -374,4 +381,12 @@ const profile = ref<any | null>(null)
const settings = ref<any | null>(null)
const logout = ref<any | null>(null)
const accountActions = ref<any | null>(null)
defineActionHandler(
"user.login",
() => {
invokeAction("modals.login.toggle")
},
computed(() => !currentUser.value)
)
</script>

View File

@@ -1,122 +0,0 @@
<template>
<HoppSmartModal
v-if="show"
styles="sm:max-w-lg"
full-width
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col border-b transition border-dividerLight">
<input
id="command"
v-model="search"
v-focus
type="text"
autocomplete="off"
name="command"
:placeholder="`${t('app.type_a_command_search')}`"
class="flex flex-shrink-0 p-6 text-base bg-transparent text-secondaryDark"
/>
<div
class="flex flex-shrink-0 text-tiny text-secondaryLight px-4 pb-4 justify-between whitespace-nowrap overflow-auto <sm:hidden"
>
<div class="flex items-center">
<kbd class="shortcut-key"></kbd>
<kbd class="shortcut-key"></kbd>
<span class="ml-2 truncate">
{{ t("action.to_navigate") }}
</span>
<kbd class="shortcut-key"></kbd>
<span class="ml-2 truncate">
{{ t("action.to_select") }}
</span>
</div>
<div class="flex items-center">
<kbd class="shortcut-key">ESC</kbd>
<span class="ml-2 truncate">
{{ t("action.to_close") }}
</span>
</div>
</div>
</div>
<AppFuse
v-if="search && show"
:input="fuse"
:search="search"
@action="runAction"
/>
<div
v-else
class="flex flex-col flex-1 overflow-auto space-y-4 divide-y divide-dividerLight"
>
<div
v-for="(map, mapIndex) in mappings"
:key="`map-${mapIndex}`"
class="flex flex-col"
>
<h5 class="px-6 py-2 my-2 text-secondaryLight">
{{ t(map.section) }}
</h5>
<AppPowerSearchEntry
v-for="(shortcut, shortcutIndex) in map.shortcuts"
:key="`map-${mapIndex}-shortcut-${shortcutIndex}`"
:shortcut="shortcut"
:active="shortcutsItems.indexOf(shortcut) === selectedEntry"
@action="runAction"
@mouseover="selectedEntry = shortcutsItems.indexOf(shortcut)"
/>
</div>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue"
import { HoppAction, invokeAction } from "~/helpers/actions"
import { spotlight as mappings, fuse } from "@helpers/shortcuts"
import { useArrowKeysNavigation } from "~/helpers/powerSearchNavigation"
import { useI18n } from "@composables/i18n"
const t = useI18n()
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const search = ref("")
const hideModal = () => {
search.value = ""
emit("hide-modal")
}
const runAction = (command: HoppAction) => {
invokeAction(command)
hideModal()
}
const shortcutsItems = computed(() =>
mappings.reduce(
(shortcuts, section) => [...shortcuts, ...section.shortcuts],
[]
)
)
const { bindArrowKeysListeners, unbindArrowKeysListeners, selectedEntry } =
useArrowKeysNavigation(shortcutsItems, {
onEnter: runAction,
})
watch(
() => props.show,
(show) => {
if (show) bindArrowKeysListeners()
else unbindArrowKeysListeners()
}
)
</script>

View File

@@ -1,68 +0,0 @@
<template>
<button
class="flex items-center flex-1 px-6 py-3 font-medium transition cursor-pointer search-entry focus:outline-none"
:class="{ active: active }"
tabindex="-1"
@click="emit('action', shortcut.action)"
@keydown.enter="emit('action', shortcut.action)"
>
<component
:is="shortcut.icon"
class="mr-4 transition opacity-50 svg-icons"
:class="{ 'opacity-100 text-secondaryDark': active }"
/>
<span
class="flex flex-1 mr-4 transition"
:class="{ 'text-secondaryDark': active }"
>
{{ t(shortcut.label) }}
</span>
<kbd
v-for="(key, keyIndex) in shortcut.keys"
:key="`key-${String(keyIndex)}`"
class="shortcut-key"
>
{{ key }}
</kbd>
</button>
</template>
<script setup lang="ts">
import type { Component } from "vue"
import { useI18n } from "@composables/i18n"
const t = useI18n()
defineProps<{
shortcut: {
label: string
keys: string[]
action: string
icon: object | Component
}
active: boolean
}>()
const emit = defineEmits<{
(e: "action", action: string): void
}>()
</script>
<style lang="scss" scoped>
.search-entry {
@apply relative;
@apply after:absolute;
@apply after:top-0;
@apply after:left-0;
@apply after:bottom-0;
@apply after:bg-transparent;
@apply after:z-2;
@apply after:w-0.5;
@apply after:content-DEFAULT;
&.active {
@apply bg-primaryLight;
@apply after:bg-accentLight;
}
}
</style>

View File

@@ -4,55 +4,26 @@
<div
class="sticky top-0 z-10 flex flex-col flex-shrink-0 overflow-x-auto bg-primary"
>
<div class="flex flex-col px-6 py-4 border-b border-dividerLight">
<input
v-model="filterText"
type="search"
autocomplete="off"
class="flex px-4 py-2 border rounded bg-primaryContrast border-divider hover:border-dividerDark focus-visible:border-dividerDark"
:placeholder="`${t('action.search')}`"
/>
</div>
<HoppSmartInput
v-model="filterText"
type="search"
styles="px-6 py-4 border-b border-dividerLight"
:placeholder="`${t('action.search')}`"
input-styles="flex px-4 py-2 border rounded bg-primaryContrast border-divider hover:border-dividerDark focus-visible:border-dividerDark"
/>
</div>
<div v-if="filterText" class="flex flex-col divide-y divide-dividerLight">
<details
v-for="(map, mapIndex) in searchResults"
:key="`map-${mapIndex}`"
class="flex flex-col"
open
>
<summary
class="flex items-center flex-1 min-w-0 px-6 py-4 font-semibold transition cursor-pointer focus:outline-none text-secondaryLight hover:text-secondaryDark"
>
<icon-lucide-chevron-right class="mr-2 indicator" />
<span
class="font-semibold truncate capitalize-first text-secondaryDark"
>
{{ t(map.item.section) }}
</span>
</summary>
<div class="flex flex-col px-6 pb-4 space-y-2">
<AppShortcutsEntry
v-for="(shortcut, index) in map.item.shortcuts"
:key="`shortcut-${index}`"
:shortcut="shortcut"
/>
</div>
</details>
<div
v-if="searchResults.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
<div class="flex flex-col divide-y divide-dividerLight">
<HoppSmartPlaceholder
v-if="isEmpty(shortcutsResults)"
:text="`${t('state.nothing_found')} ‟${filterText}”`"
>
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
<span class="my-2 text-center">
{{ t("state.nothing_found") }} "{{ filterText }}"
</span>
</div>
</div>
<div v-else class="flex flex-col divide-y divide-dividerLight">
</HoppSmartPlaceholder>
<details
v-for="(map, mapIndex) in mappings"
:key="`map-${mapIndex}`"
v-for="(sectionResults, sectionTitle) in shortcutsResults"
v-else
:key="`section-${sectionTitle}`"
class="flex flex-col"
open
>
@@ -63,13 +34,13 @@
<span
class="font-semibold truncate capitalize-first text-secondaryDark"
>
{{ t(map.section) }}
{{ sectionTitle }}
</span>
</summary>
<div class="flex flex-col px-6 pb-4 space-y-2">
<AppShortcutsEntry
v-for="(shortcut, shortcutIndex) in map.shortcuts"
:key="`map-${mapIndex}-shortcut-${shortcutIndex}`"
v-for="(shortcut, index) in sectionResults"
:key="`shortcut-${index}`"
:shortcut="shortcut"
/>
</div>
@@ -80,10 +51,11 @@
</template>
<script setup lang="ts">
import { computed, ref } from "vue"
import Fuse from "fuse.js"
import mappings from "~/helpers/shortcuts"
import { computed, onBeforeMount, ref } from "vue"
import { ShortcutDef, getShortcuts } from "~/helpers/shortcuts"
import MiniSearch from "minisearch"
import { useI18n } from "@composables/i18n"
import { groupBy, isEmpty } from "lodash-es"
const t = useI18n()
@@ -91,15 +63,33 @@ defineProps<{
show: boolean
}>()
const options = {
keys: ["shortcuts.label"],
}
const minisearch = new MiniSearch({
fields: ["label", "keys", "section"],
idField: "label",
storeFields: ["label", "keys", "section"],
searchOptions: {
fuzzy: true,
prefix: true,
},
})
const fuse = new Fuse(mappings, options)
const shortcuts = getShortcuts(t)
onBeforeMount(() => {
minisearch.addAllAsync(shortcuts)
})
const filterText = ref("")
const searchResults = computed(() => fuse.search(filterText.value))
const shortcutsResults = computed(() => {
// If there are no search text, return all the shortcuts
const results =
filterText.value.length > 0
? minisearch.search(filterText.value)
: shortcuts
return groupBy(results, "section") as Record<string, ShortcutDef[]>
})
const emit = defineEmits<{
(e: "close"): void

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex items-center py-1">
<span class="flex flex-1 mr-4">
{{ t(shortcut.label) }}
{{ shortcut.label }}
</span>
<kbd
v-for="(key, index) in shortcut.keys"
@@ -14,14 +14,9 @@
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
const t = useI18n()
import { ShortcutDef } from "~/helpers/shortcuts"
defineProps<{
shortcut: {
label: string
keys: string[]
}
shortcut: ShortcutDef
}>()
</script>

View File

@@ -22,10 +22,11 @@
</div>
<div class="flex">
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
<kbd class="shortcut-key">K</kbd>
<kbd class="shortcut-key">/</kbd>
</div>
<div class="flex">
<kbd class="shortcut-key">/</kbd>
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
<kbd class="shortcut-key">K</kbd>
</div>
<div class="flex">
<kbd class="shortcut-key">?</kbd>

View File

@@ -0,0 +1,122 @@
<template>
<button
ref="el"
class="flex items-center flex-1 px-6 py-4 font-medium space-x-4 transition cursor-pointer relative search-entry focus:outline-none"
:class="{ 'active bg-primaryLight text-secondaryDark': active }"
tabindex="-1"
@click="emit('action')"
@keydown.enter="emit('action')"
>
<component
:is="entry.icon"
class="opacity-50 svg-icons"
:class="{ 'opacity-100': active }"
/>
<template
v-if="entry.text.type === 'text' && typeof entry.text.text === 'string'"
>
<span class="block truncate">
{{ entry.text.text }}
</span>
</template>
<template
v-else-if="entry.text.type === 'text' && Array.isArray(entry.text.text)"
>
<template
v-for="(labelPart, labelPartIndex) in entry.text.text"
:key="`label-${labelPart}-${labelPartIndex}`"
>
<span class="block truncate">
{{ labelPart }}
</span>
<icon-lucide-chevron-right
v-if="labelPartIndex < entry.text.text.length - 1"
class="flex flex-shrink-0"
/>
</template>
</template>
<template v-else-if="entry.text.type === 'custom'">
<span class="block truncate">
<component
:is="entry.text.component"
v-bind="entry.text.componentProps"
/>
</span>
</template>
<span v-if="formattedShortcutKeys" class="block truncate">
<kbd
v-for="(key, keyIndex) in formattedShortcutKeys"
:key="`key-${String(keyIndex)}`"
class="shortcut-key"
>
{{ key }}
</kbd>
</span>
</button>
</template>
<script lang="ts">
import { getPlatformSpecialKey } from "~/helpers/platformutils"
import { SpotlightSearcherResult } from "~/services/spotlight"
const SPECIAL_KEY_CHARS: Record<string, string> = {
ctrl: getPlatformSpecialKey(),
alt: getPlatformAlternateKey(),
up: "↑",
down: "↓",
enter: "↩",
}
</script>
<script setup lang="ts">
import { computed, watch, ref } from "vue"
import { capitalize } from "lodash-es"
import { getPlatformAlternateKey } from "~/helpers/platformutils"
const el = ref<HTMLElement>()
const props = defineProps<{
entry: SpotlightSearcherResult
active: boolean
}>()
const formattedShortcutKeys = computed(() =>
props.entry.meta?.keyboardShortcut?.map((key) => {
return SPECIAL_KEY_CHARS[key] ?? capitalize(key)
})
)
const emit = defineEmits<{
(e: "action"): void
}>()
watch(
() => props.active,
(active) => {
if (active) {
el.value?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest",
})
}
}
)
</script>
<style lang="scss" scoped>
.search-entry {
@apply after:absolute;
@apply after:top-0;
@apply after:left-0;
@apply after:bottom-0;
@apply after:bg-transparent;
@apply after:z-2;
@apply after:w-0.5;
@apply after:content-DEFAULT;
&.active {
@apply after:bg-accentLight;
}
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<span class="flex flex-1 items-center space-x-2">
<span class="block truncate">
{{ dateTimeText }}
</span>
<icon-lucide-chevron-right class="flex flex-shrink-0" />
<span class="block truncate">
{{ historyEntry.request.url }}
</span>
<span
class="font-semibold truncate text-tiny flex flex-shrink-0 border border-dividerDark rounded-md px-1"
>
{{ historyEntry.request.query.split("\n")[0] }}
</span>
</span>
</template>
<script setup lang="ts">
import { computed } from "vue"
import { shortDateTime } from "~/helpers/utils/date"
import { GQLHistoryEntry } from "~/newstore/history"
const props = defineProps<{
historyEntry: GQLHistoryEntry
}>()
const dateTimeText = computed(() =>
shortDateTime(props.historyEntry.updatedOn!)
)
</script>

View File

@@ -0,0 +1,43 @@
<template>
<span class="flex flex-1 items-center space-x-2">
<span class="block truncate">
{{ dateTimeText }}
</span>
<icon-lucide-chevron-right class="flex flex-shrink-0" />
<span
class="font-semibold truncate text-tiny flex flex-shrink-0 border border-dividerDark rounded-md px-1"
:class="entryStatus.className"
>
{{ historyEntry.request.method }}
</span>
<span class="block truncate">
{{ historyEntry.request.endpoint }}
</span>
</span>
</template>
<script setup lang="ts">
import { computed } from "vue"
import findStatusGroup from "~/helpers/findStatusGroup"
import { shortDateTime } from "~/helpers/utils/date"
import { RESTHistoryEntry } from "~/newstore/history"
const props = defineProps<{
historyEntry: RESTHistoryEntry
}>()
const dateTimeText = computed(() =>
shortDateTime(props.historyEntry.updatedOn!)
)
const entryStatus = computed(() => {
const foundStatusGroup = findStatusGroup(
props.historyEntry.responseMeta.statusCode
)
return (
foundStatusGroup || {
className: "",
}
)
})
</script>

View File

@@ -0,0 +1,238 @@
<template>
<HoppSmartModal
v-if="show"
styles="sm:max-w-lg"
full-width
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col border-b transition border-divider">
<div class="flex items-center">
<input
id="command"
v-model="search"
v-focus
type="text"
autocomplete="off"
name="command"
:placeholder="`${t('app.type_a_command_search')}`"
class="flex flex-1 text-base bg-transparent text-secondaryDark px-6 py-5"
/>
<HoppSmartSpinner v-if="searchSession?.loading" class="mr-6" />
</div>
</div>
<div
v-if="searchSession && search.length > 0"
class="flex flex-col flex-1 overflow-y-auto border-b border-divider divide-y divide-dividerLight"
>
<div
v-for="([sectionID, sectionResult], sectionIndex) in scoredResults"
:key="`section-${sectionID}`"
class="flex flex-col"
>
<h5
class="px-6 py-2 bg-primaryContrast z-10 text-secondaryLight sticky top-0"
>
{{ sectionResult.title }}
</h5>
<AppSpotlightEntry
v-for="(result, entryIndex) in sectionResult.results"
:key="`result-${result.id}`"
:entry="result"
:active="isEqual(selectedEntry, [sectionIndex, entryIndex])"
@mouseover="selectedEntry = [sectionIndex, entryIndex]"
@action="runAction(sectionID, result)"
/>
</div>
<HoppSmartPlaceholder
v-if="search.length > 0 && scoredResults.length === 0"
:text="`${t('state.nothing_found')} ‟${search}”`"
>
<template #icon>
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
</template>
<HoppButtonSecondary
:label="t('action.clear')"
outline
@click="search = ''"
/>
</HoppSmartPlaceholder>
</div>
<div
class="flex flex-shrink-0 text-tiny text-secondaryLight p-4 justify-between whitespace-nowrap overflow-auto <sm:hidden"
>
<div class="flex items-center">
<kbd class="shortcut-key"></kbd>
<kbd class="shortcut-key"></kbd>
<span class="mx-2 truncate">
{{ t("action.to_navigate") }}
</span>
<kbd class="shortcut-key"></kbd>
<span class="ml-2 truncate">
{{ t("action.to_select") }}
</span>
</div>
<div class="flex items-center">
<kbd class="shortcut-key">ESC</kbd>
<span class="ml-2 truncate">
{{ t("action.to_close") }}
</span>
</div>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue"
import { useService } from "dioc/vue"
import { useI18n } from "@composables/i18n"
import {
SpotlightService,
SpotlightSearchState,
SpotlightSearcherResult,
} from "~/services/spotlight"
import { isEqual } from "lodash-es"
import { HistorySpotlightSearcherService } from "~/services/spotlight/searchers/history.searcher"
import { UserSpotlightSearcherService } from "~/services/spotlight/searchers/user.searcher"
const t = useI18n()
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const spotlightService = useService(SpotlightService)
useService(HistorySpotlightSearcherService)
useService(UserSpotlightSearcherService)
const search = ref("")
const searchSession = ref<SpotlightSearchState>()
const stopSearchSession = ref<() => void>()
const scoredResults = computed(() =>
Object.entries(searchSession.value?.results ?? {}).sort(
([, sectionA], [, sectionB]) => sectionB.avgScore - sectionA.avgScore
)
)
const { selectedEntry } = newUseArrowKeysForNavigation()
watch(
() => props.show,
(show) => {
search.value = ""
if (show) {
const [session, onSessionEnd] =
spotlightService.createSearchSession(search)
searchSession.value = session.value
stopSearchSession.value = onSessionEnd
} else {
stopSearchSession.value?.()
stopSearchSession.value = undefined
searchSession.value = undefined
}
}
)
function runAction(searcherID: string, result: SpotlightSearcherResult) {
spotlightService.selectSearchResult(searcherID, result)
emit("hide-modal")
}
function newUseArrowKeysForNavigation() {
const selectedEntry = ref<[number, number]>([0, 0]) // [sectionIndex, entryIndex]
watch(search, () => {
selectedEntry.value = [0, 0]
})
const onArrowDown = () => {
// If no entries, do nothing
if (scoredResults.value.length === 0) return
const [sectionIndex, entryIndex] = selectedEntry.value
const [, section] = scoredResults.value[sectionIndex]
if (entryIndex < section.results.length - 1) {
selectedEntry.value = [sectionIndex, entryIndex + 1]
} else if (sectionIndex < scoredResults.value.length - 1) {
selectedEntry.value = [sectionIndex + 1, 0]
} else {
selectedEntry.value = [0, 0]
}
}
const onArrowUp = () => {
// If no entries, do nothing
if (scoredResults.value.length === 0) return
const [sectionIndex, entryIndex] = selectedEntry.value
if (entryIndex > 0) {
selectedEntry.value = [sectionIndex, entryIndex - 1]
} else if (sectionIndex > 0) {
const [, section] = scoredResults.value[sectionIndex - 1]
selectedEntry.value = [sectionIndex - 1, section.results.length - 1]
} else {
selectedEntry.value = [
scoredResults.value.length - 1,
scoredResults.value[scoredResults.value.length - 1][1].results.length -
1,
]
}
}
const onEnter = () => {
// If no entries, do nothing
if (scoredResults.value.length === 0) return
const [sectionIndex, entryIndex] = selectedEntry.value
const [sectionID, section] = scoredResults.value[sectionIndex]
const result = section.results[entryIndex]
runAction(sectionID, result)
}
function handleKeyPress(e: KeyboardEvent) {
if (e.key === "ArrowUp") {
e.preventDefault()
e.stopPropagation()
onArrowUp()
} else if (e.key === "ArrowDown") {
e.preventDefault()
e.stopPropagation()
onArrowDown()
} else if (e.key === "Enter") {
e.preventDefault()
e.stopPropagation()
onEnter()
}
}
watch(
() => props.show,
(show) => {
if (show) {
window.addEventListener("keydown", handleKeyPress)
} else {
window.removeEventListener("keydown", handleKeyPress)
}
}
)
return { selectedEntry }
}
</script>

View File

@@ -6,21 +6,13 @@
@close="hideModal"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelAdd"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="addNewCollection"
/>
<label for="selectLabelAdd">
{{ t("action.label") }}
</label>
</div>
<HoppSmartInput
v-model="name"
placeholder=" "
:label="t('action.label')"
input-styles="floating-input"
@submit="addNewCollection"
/>
</template>
<template #footer>
<span class="flex space-x-2">

View File

@@ -6,21 +6,13 @@
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelAddFolder"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="addFolder"
/>
<label for="selectLabelAddFolder">
{{ t("action.label") }}
</label>
</div>
<HoppSmartInput
v-model="name"
placeholder=" "
input-styles="floating-input"
:label="t('action.label')"
@submit="addFolder"
/>
</template>
<template #footer>
<span class="flex space-x-2">

View File

@@ -6,19 +6,13 @@
@close="$emit('hide-modal')"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelAddRequest"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="addRequest"
/>
<label for="selectLabelAddRequest">{{ t("action.label") }}</label>
</div>
<HoppSmartInput
v-model="name"
placeholder=" "
:label="t('action.label')"
input-styles="floating-input"
@submit="addRequest"
/>
</template>
<template #footer>
<span class="flex space-x-2">

View File

@@ -193,7 +193,7 @@ import IconTrash2 from "~icons/lucide/trash-2"
import IconEdit from "~icons/lucide/edit"
import IconFolder from "~icons/lucide/folder"
import IconFolderOpen from "~icons/lucide/folder-open"
import { PropType, ref, computed, watch } from "vue"
import { ref, computed, watch } from "vue"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { useI18n } from "@composables/i18n"
import { TippyComponent } from "vue-tippy"
@@ -209,67 +209,36 @@ type FolderType = "collection" | "folder"
const t = useI18n()
const props = defineProps({
id: {
type: String,
default: "",
required: true,
},
parentID: {
type: String as PropType<string | null>,
default: null,
required: false,
},
data: {
type: Object as PropType<HoppCollection<HoppRESTRequest> | TeamCollection>,
default: () => ({}),
required: true,
},
collectionsType: {
type: String as PropType<CollectionType>,
default: "my-collections",
required: true,
},
/**
* Collection component can be used for both collections and folders.
* folderType is used to determine which one it is.
*/
folderType: {
type: String as PropType<FolderType>,
default: "collection",
required: true,
},
isOpen: {
type: Boolean,
default: false,
required: true,
},
isSelected: {
type: Boolean as PropType<boolean | null>,
default: false,
required: false,
},
exportLoading: {
type: Boolean,
default: false,
required: false,
},
hasNoTeamAccess: {
type: Boolean,
default: false,
required: false,
},
collectionMoveLoading: {
type: Array as PropType<string[]>,
default: () => [],
required: false,
},
isLastItem: {
type: Boolean,
default: false,
required: false,
},
})
const props = withDefaults(
defineProps<{
id: string
parentID?: string | null
data: HoppCollection<HoppRESTRequest> | TeamCollection
/**
* Collection component can be used for both collections and folders.
* folderType is used to determine which one it is.
*/
collectionsType: CollectionType
folderType: FolderType
isOpen: boolean
isSelected?: boolean | null
exportLoading?: boolean
hasNoTeamAccess?: boolean
collectionMoveLoading?: string[]
isLastItem?: boolean
}>(),
{
id: "",
parentID: null,
collectionsType: "my-collections",
folderType: "collection",
isOpen: false,
isSelected: false,
exportLoading: false,
hasNoTeamAccess: false,
isLastItem: false,
}
)
const emit = defineEmits<{
(event: "toggle-children"): void
@@ -448,8 +417,13 @@ const notSameDestination = computed(() => {
})
const isCollLoading = computed(() => {
if (props.collectionMoveLoading.length > 0 && props.data.id) {
return props.collectionMoveLoading.includes(props.data.id)
const { collectionMoveLoading } = props
if (
collectionMoveLoading &&
collectionMoveLoading.length > 0 &&
props.data.id
) {
return collectionMoveLoading.includes(props.data.id)
} else {
return false
}

View File

@@ -6,21 +6,13 @@
@close="hideModal"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelEdit"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="saveCollection"
/>
<label for="selectLabelEdit">
{{ t("action.label") }}
</label>
</div>
<HoppSmartInput
v-model="name"
placeholder=" "
input-styles="floating-input"
:label="t('action.label')"
@submit="saveCollection"
/>
</template>
<template #footer>
<span class="flex space-x-2">

View File

@@ -6,21 +6,13 @@
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelEditFolder"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="editFolder"
/>
<label for="selectLabelEditFolder">
{{ t("action.label") }}
</label>
</div>
<HoppSmartInput
v-model="name"
placeholder=" "
:label="t('action.label')"
input-styles="floating-input"
@submit="editFolder"
/>
</template>
<template #footer>
<span class="flex space-x-2">

View File

@@ -6,21 +6,13 @@
@close="hideModal"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelEditReq"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="editRequest"
/>
<label for="selectLabelEditReq">
{{ t("action.label") }}
</label>
</div>
<HoppSmartInput
v-model="name"
placeholder=" "
:label="t('action.label')"
input-styles="floating-input"
@submit="editRequest"
/>
</template>
<template #footer>
<span class="flex space-x-2">

View File

@@ -284,6 +284,14 @@ const importerAction = async (stepResults: StepReturnValue[]) => {
emit("import-to-teams", result)
} else {
appendRESTCollections(result)
platform.analytics?.logEvent({
type: "HOPP_IMPORT_COLLECTION",
importer: importerModule.value!.name,
platform: "rest",
workspaceType: "personal",
})
fileImported()
}
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col flex-1 bg-primary">
<div class="flex flex-col flex-1">
<div
class="sticky z-10 flex justify-between flex-1 border-b bg-primary border-dividerLight"
:style="
@@ -243,49 +243,33 @@
/>
</template>
<template #emptyNode="{ node }">
<div
<HoppSmartPlaceholder
v-if="filterText.length !== 0 && filteredCollections.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
:text="`${t('state.nothing_found')}${filterText}`"
>
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
<span class="my-2 text-center">
{{ t("state.nothing_found") }} "{{ filterText }}"
</span>
</div>
<div v-else-if="node === null">
<div
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collections')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.collections") }}
</span>
<HoppButtonSecondary
:label="t('add.new')"
filled
outline
@click="emit('display-modal-add')"
/>
</div>
</div>
<div
v-else-if="node.data.type === 'collections'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
<template #icon>
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
</template>
</HoppSmartPlaceholder>
<HoppSmartPlaceholder
v-else-if="node === null"
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collection')}`"
<HoppButtonSecondary
:label="t('add.new')"
filled
outline
@click="emit('display-modal-add')"
/>
<span class="pb-4 text-center">
{{ t("empty.collection") }}
</span>
</HoppSmartPlaceholder>
<HoppSmartPlaceholder
v-else-if="node.data.type === 'collections'"
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
>
<HoppButtonSecondary
:label="t('add.new')"
filled
@@ -298,21 +282,14 @@
})
"
/>
</div>
<div
</HoppSmartPlaceholder>
<HoppSmartPlaceholder
v-else-if="node.data.type === 'folders'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.folder')}`"
:text="t('empty.folder')"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.folder')}`"
/>
<span class="text-center">
{{ t("empty.folder") }}
</span>
</div>
</HoppSmartPlaceholder>
</template>
</SmartTree>
</div>

View File

@@ -8,21 +8,15 @@
>
<template #body>
<div class="flex flex-col">
<div class="relative flex">
<input
id="selectLabelSaveReq"
v-model="requestName"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="saveRequestAs"
/>
<label for="selectLabelSaveReq">
{{ t("request.name") }}
</label>
</div>
<HoppSmartInput
v-model="requestName"
styles="relative flex"
placeholder=" "
:label="t('request.name')"
input-styles="floating-input"
@submit="saveRequestAs"
/>
<label class="p-4">
{{ t("collection.select_location") }}
</label>
@@ -62,7 +56,7 @@
</template>
<script setup lang="ts">
import { nextTick, reactive, ref, watch } from "vue"
import { computed, nextTick, reactive, ref, watch } from "vue"
import { cloneDeep } from "lodash-es"
import {
HoppGQLRequest,
@@ -89,6 +83,7 @@ import {
import { GQLError } from "~/helpers/backend/GQLClient"
import { computedWithControl } from "@vueuse/core"
import { currentActiveTab } from "~/helpers/rest/tab"
import { platform } from "~/platform"
const t = useI18n()
const toast = useToast()
@@ -106,10 +101,12 @@ const props = withDefaults(
defineProps<{
show: boolean
mode: "rest" | "graphql"
request?: HoppRESTRequest | HoppGQLRequest | null
}>(),
{
show: false,
mode: "rest",
request: null,
}
)
@@ -131,9 +128,17 @@ const restRequestName = computedWithControl(
() => currentActiveTab.value.document.request.name
)
const requestName = ref(
props.mode === "rest" ? restRequestName.value : gqlRequestName.value
)
const reqName = computed(() => {
if (props.request) {
return props.request.name
} else if (props.mode === "rest") {
return restRequestName.value
} else {
return gqlRequestName.value
}
})
const requestName = ref(reqName.value)
watch(
() => [currentActiveTab.value, gqlRequestName.value],
@@ -197,10 +202,15 @@ const saveRequestAs = async () => {
return
}
const requestUpdated =
props.mode === "rest"
? cloneDeep(currentActiveTab.value.document.request)
: cloneDeep(getGQLSession().request)
let requestUpdated
if (props.request) {
requestUpdated = cloneDeep(props.request)
} else if (props.mode === "rest") {
requestUpdated = cloneDeep(currentActiveTab.value.document.request)
} else {
requestUpdated = cloneDeep(getGQLSession().request)
}
requestUpdated.name = requestName.value
@@ -223,6 +233,13 @@ const saveRequestAs = async () => {
},
}
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "rest",
workspaceType: "personal",
})
requestSaved()
} else if (picked.value.pickedType === "my-folder") {
if (!isHoppRESTRequest(requestUpdated))
@@ -243,6 +260,13 @@ const saveRequestAs = async () => {
},
}
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "rest",
workspaceType: "personal",
})
requestSaved()
} else if (picked.value.pickedType === "my-request") {
if (!isHoppRESTRequest(requestUpdated))
@@ -264,17 +288,38 @@ const saveRequestAs = async () => {
},
}
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: false,
platform: "rest",
workspaceType: "personal",
})
requestSaved()
} else if (picked.value.pickedType === "teams-collection") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
updateTeamCollectionOrFolder(picked.value.collectionID, requestUpdated)
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "rest",
workspaceType: "team",
})
} else if (picked.value.pickedType === "teams-folder") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
updateTeamCollectionOrFolder(picked.value.folderID, requestUpdated)
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "rest",
workspaceType: "team",
})
} else if (picked.value.pickedType === "teams-request") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
@@ -292,6 +337,13 @@ const saveRequestAs = async () => {
title: requestUpdated.name,
}
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: false,
platform: "rest",
workspaceType: "team",
})
pipe(
updateTeamRequest(picked.value.requestID, data),
TE.match(
@@ -313,6 +365,13 @@ const saveRequestAs = async () => {
requestUpdated as HoppGQLRequest
)
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: false,
platform: "gql",
workspaceType: "team",
})
requestSaved()
} else if (picked.value.pickedType === "gql-my-folder") {
// TODO: Check for GQL request ?
@@ -321,6 +380,13 @@ const saveRequestAs = async () => {
requestUpdated as HoppGQLRequest
)
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "gql",
workspaceType: "team",
})
requestSaved()
} else if (picked.value.pickedType === "gql-my-collection") {
// TODO: Check for GQL request ?
@@ -329,6 +395,13 @@ const saveRequestAs = async () => {
requestUpdated as HoppGQLRequest
)
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "gql",
workspaceType: "team",
})
requestSaved()
}
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col flex-1 bg-primary">
<div class="flex flex-col flex-1">
<div
class="sticky z-10 flex justify-between flex-1 border-b bg-primary border-dividerLight"
:style="
@@ -262,67 +262,53 @@
</template>
<template #emptyNode="{ node }">
<div v-if="node === null">
<div
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
@drop="(e) => e.stopPropagation()"
>
<img
<div @drop="(e) => e.stopPropagation()">
<HoppSmartPlaceholder
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collection')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.collections") }}
</span>
<HoppButtonSecondary
v-if="hasNoTeamAccess"
v-tippy="{ theme: 'tooltip' }"
disabled
filled
outline
:title="t('team.no_access')"
:label="t('action.new')"
/>
<HoppButtonSecondary
v-else
:icon="IconPlus"
:label="t('action.new')"
filled
outline
@click="emit('display-modal-add')"
/>
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
>
<HoppButtonSecondary
v-if="hasNoTeamAccess"
v-tippy="{ theme: 'tooltip' }"
disabled
filled
outline
:title="t('team.no_access')"
:label="t('action.new')"
/>
<HoppButtonSecondary
v-else
:icon="IconPlus"
:label="t('action.new')"
filled
outline
@click="emit('display-modal-add')"
/>
</HoppSmartPlaceholder>
</div>
</div>
<div
v-else-if="node.data.type === 'collections'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
@drop="(e) => e.stopPropagation()"
>
<img
<HoppSmartPlaceholder
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collection')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.collections") }}
</span>
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
>
</HoppSmartPlaceholder>
</div>
<div
v-else-if="node.data.type === 'folders'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
@drop="(e) => e.stopPropagation()"
>
<img
<HoppSmartPlaceholder
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.folder')}`"
/>
<span class="text-center">
{{ t("empty.folder") }}
</span>
:text="t('empty.folder')"
>
</HoppSmartPlaceholder>
</div>
</template>
</SmartTree>

View File

@@ -6,21 +6,13 @@
@close="hideModal"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelGqlAdd"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="addNewCollection"
/>
<label for="selectLabelGqlAdd">
{{ t("action.label") }}
</label>
</div>
<HoppSmartInput
v-model="name"
placeholder=" "
input-styles="floating-input"
:label="t('action.label')"
@submit="addNewCollection"
/>
</template>
<template #footer>
<span class="flex space-x-2">
@@ -46,6 +38,7 @@ import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { HoppGQLRequest, makeCollection } from "@hoppscotch/data"
import { addGraphqlCollection } from "~/newstore/collections"
import { platform } from "~/platform"
export default defineComponent({
props: {
@@ -79,6 +72,13 @@ export default defineComponent({
)
this.hideModal()
platform.analytics?.logEvent({
type: "HOPP_CREATE_COLLECTION",
isRootCollection: true,
platform: "gql",
workspaceType: "personal",
})
},
hideModal() {
this.name = null

View File

@@ -6,21 +6,13 @@
@close="$emit('hide-modal')"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelGqlAddFolder"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="addFolder"
/>
<label for="selectLabelGqlAddFolder">
{{ t("action.label") }}
</label>
</div>
<HoppSmartInput
v-model="name"
placeholder=" "
:label="t('action.label')"
input-styles="floating-input"
@submit="addFolder"
/>
</template>
<template #footer>
<span class="flex space-x-2">

View File

@@ -6,21 +6,13 @@
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelGqlAddRequest"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="addRequest"
/>
<label for="selectLabelGqlAddRequest">
{{ t("action.label") }}
</label>
</div>
<HoppSmartInput
v-model="name"
placeholder=" "
:label="t('action.label')"
input-styles="floating-input"
@submit="addRequest"
/>
</template>
<template #footer>
<span class="flex space-x-2">

View File

@@ -171,21 +171,14 @@
@duplicate-request="$emit('duplicate-request', $event)"
@select="$emit('select', $event)"
/>
<div
<HoppSmartPlaceholder
v-if="
collection.folders.length === 0 && collection.requests.length === 0
"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.collection')}`"
:text="t('empty.collection')"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collection')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.collection") }}
</span>
<HoppButtonSecondary
:label="t('add.new')"
filled
@@ -196,7 +189,7 @@
})
"
/>
</div>
</HoppSmartPlaceholder>
</div>
</div>
<HoppSmartConfirmModal

View File

@@ -6,21 +6,13 @@
@close="hideModal"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelGqlEdit"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="saveCollection"
/>
<label for="selectLabelGqlEdit">
{{ t("action.label") }}
</label>
</div>
<HoppSmartInput
v-model="name"
placeholder=" "
:label="t('action.label')"
input-styles="floating-input"
@submit="saveCollection"
/>
</template>
<template #footer>
<span class="flex space-x-2">

View File

@@ -6,21 +6,13 @@
@close="$emit('hide-modal')"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelGqlEditFolder"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="editFolder"
/>
<label for="selectLabelGqlEditFolder">
{{ t("action.label") }}
</label>
</div>
<HoppSmartInput
v-model="name"
placeholder=" "
:label="t('action.label')"
input-styles="floating-input"
@submit="editFolder"
/>
</template>
<template #footer>
<span class="flex space-x-2">

View File

@@ -6,21 +6,13 @@
@close="hideModal"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelGqlEditReq"
v-model="requestUpdateData.name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="saveRequest"
/>
<label for="selectLabelGqlEditReq">
{{ t("action.label") }}
</label>
</div>
<HoppSmartInput
v-model="requestUpdateData.name"
placeholder=" "
:label="t('action.label')"
input-styles="floating-input"
@submit="saveRequest"
/>
</template>
<template #footer>
<span class="flex space-x-2">

View File

@@ -160,25 +160,19 @@
@duplicate-request="emit('duplicate-request', $event)"
@select="emit('select', $event)"
/>
<div
<HoppSmartPlaceholder
v-if="
folder.folders &&
folder.folders.length === 0 &&
folder.requests &&
folder.requests.length === 0
"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.folder')}`"
:text="t('empty.folder')"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.folder')}`"
/>
<span class="text-center">
{{ t("empty.folder") }}
</span>
</div>
</HoppSmartPlaceholder>
</div>
</div>
<HoppSmartConfirmModal

View File

@@ -244,6 +244,14 @@ const importFromJSON = () => {
return
}
appendGraphqlCollections(collections)
platform.analytics?.logEvent({
type: "HOPP_IMPORT_COLLECTION",
importer: "json",
workspaceType: "personal",
platform: "gql",
})
fileImported()
}
reader.readAsText(inputChooseFileToImportFrom.value.files[0])
@@ -257,6 +265,12 @@ const exportJSON = () => {
const url = URL.createObjectURL(file)
a.href = url
platform?.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "gql",
})
// TODO: get uri from meta
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
document.body.appendChild(a)

View File

@@ -11,7 +11,7 @@
type="search"
autocomplete="off"
:placeholder="t('action.search')"
class="py-2 pl-4 pr-2 bg-transparent"
class="py-2 pl-4 pr-2 bg-transparent !border-0"
/>
<div
class="flex justify-between flex-1 flex-shrink-0 border-y bg-primary border-dividerLight"
@@ -60,35 +60,27 @@
@select="$emit('select', $event)"
/>
</div>
<div
<HoppSmartPlaceholder
v-if="collections.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="t('empty.collections')"
/>
<span class="pb-4 text-center">
{{ t("empty.collections") }}
</span>
<HoppButtonSecondary
:label="t('add.new')"
filled
outline
@click="displayModalAdd(true)"
/>
</div>
<div
</HoppSmartPlaceholder>
<HoppSmartPlaceholder
v-if="!(filteredCollections.length !== 0 || collections.length === 0)"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
:text="`${t('state.nothing_found')}${filterText}`"
>
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
<span class="my-2 text-center">
{{ t("state.nothing_found") }} "{{ filterText }}"
</span>
</div>
<template #icon>
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
</template>
</HoppSmartPlaceholder>
<CollectionsGraphqlAdd
:show="showModalAdd"
@hide-modal="displayModalAdd(false)"
@@ -153,6 +145,7 @@ import IconArchive from "~icons/lucide/archive"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useColorMode } from "@composables/theming"
import { platform } from "~/platform"
export default defineComponent({
props: {
@@ -285,6 +278,13 @@ export default defineComponent({
response: "",
})
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
platform: "gql",
createdNow: true,
workspaceType: "personal",
})
this.displayModalAddRequest(false)
},
addRequest(payload) {
@@ -294,6 +294,14 @@ export default defineComponent({
},
onAddFolder({ name, path }) {
addGraphqlFolder(name, path)
platform.analytics?.logEvent({
type: "HOPP_CREATE_COLLECTION",
isRootCollection: false,
platform: "gql",
workspaceType: "personal",
})
this.displayModalAddFolder(false)
},
addFolder(payload) {

View File

@@ -18,12 +18,12 @@
"
>
<WorkspaceCurrent :section="t('tab.collections')" />
<input
<HoppSmartInput
v-model="filterTexts"
type="search"
autocomplete="off"
:placeholder="t('action.search')"
class="py-2 pl-4 pr-2 bg-transparent"
input-styles="py-2 pl-4 pr-2 bg-transparent !border-0"
type="search"
:disabled="collectionsType.type === 'team-collections'"
/>
</div>
@@ -599,11 +599,25 @@ const addNewRootCollection = (name: string) => {
})
)
platform.analytics?.logEvent({
type: "HOPP_CREATE_COLLECTION",
platform: "rest",
workspaceType: "personal",
isRootCollection: true,
})
displayModalAdd(false)
} else if (hasTeamWriteAccess.value) {
if (!collectionsType.value.selectedTeam) return
modalLoadingState.value = true
platform.analytics?.logEvent({
type: "HOPP_CREATE_COLLECTION",
platform: "rest",
workspaceType: "team",
isRootCollection: true,
})
pipe(
createNewRootCollection(name, collectionsType.value.selectedTeam.id),
TE.match(
@@ -652,6 +666,13 @@ const onAddRequest = (requestName: string) => {
},
})
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
workspaceType: "personal",
createdNow: true,
platform: "rest",
})
displayModalAddRequest(false)
} else if (hasTeamWriteAccess.value) {
const folder = editingFolder.value
@@ -667,6 +688,13 @@ const onAddRequest = (requestName: string) => {
title: requestName,
}
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
workspaceType: "team",
platform: "rest",
createdNow: true,
})
pipe(
createRequestInCollection(folder.id, data),
TE.match(
@@ -712,6 +740,14 @@ const onAddFolder = (folderName: string) => {
if (collectionsType.value.type === "my-collections") {
if (!path) return
addRESTFolder(folderName, path)
platform.analytics?.logEvent({
type: "HOPP_CREATE_COLLECTION",
workspaceType: "personal",
isRootCollection: false,
platform: "rest",
})
displayModalAddFolder(false)
} else if (hasTeamWriteAccess.value) {
const folder = editingFolder.value
@@ -719,6 +755,13 @@ const onAddFolder = (folderName: string) => {
modalLoadingState.value = true
platform.analytics?.logEvent({
type: "HOPP_CREATE_COLLECTION",
workspaceType: "personal",
isRootCollection: false,
platform: "rest",
})
pipe(
createChildCollection(folderName, folder.id),
TE.match(
@@ -1884,6 +1927,12 @@ const exportData = async (
}
const exportJSONCollection = async () => {
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "rest",
})
await getJSONCollection()
initializeDownloadCollection(collectionJSON.value, null)
@@ -1895,6 +1944,12 @@ const createCollectionGist = async () => {
return
}
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "gist",
platform: "rest",
})
creatingGistCollection.value = true
await getJSONCollection()
@@ -1925,6 +1980,12 @@ const importToTeams = async (collection: HoppCollection<HoppRESTRequest>[]) => {
importingMyCollections.value = true
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "import-to-teams",
platform: "rest",
})
pipe(
importJSONToTeam(
JSON.stringify(collection),

View File

@@ -0,0 +1,208 @@
<template>
<HoppSmartModal
v-if="show"
:title="t('environment.set_as_environment')"
@close="hideModal"
>
<template #body>
<div class="flex space-y-4 flex-1 flex-col">
<div class="flex items-center space-x-8 ml-2">
<label for="name" class="font-semibold min-w-10">{{
t("environment.name")
}}</label>
<input
v-model="name"
type="text"
:placeholder="t('environment.variable')"
class="input"
/>
</div>
<div class="flex items-center space-x-8 ml-2">
<label for="value" class="font-semibold min-w-10">{{
t("environment.value")
}}</label>
<input type="text" :value="value" class="input" />
</div>
<div class="flex items-center space-x-8 ml-2">
<label for="scope" class="font-semibold min-w-10">
{{ t("environment.scope") }}
</label>
<div
class="relative flex flex-1 flex-col border border-divider rounded focus-visible:border-dividerDark"
>
<EnvironmentsSelector v-model="scope" :is-scope-selector="true" />
</div>
</div>
<div v-if="replaceWithVariable" class="flex space-x-2 mt-3">
<div class="min-w-18" />
<HoppSmartCheckbox
:on="replaceWithVariable"
title="t('environment.replace_with_variable'))"
@change="replaceWithVariable = !replaceWithVariable"
/>
<label for="replaceWithVariable">
{{ t("environment.replace_with_variable") }}</label
>
</div>
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<HoppButtonPrimary
:label="t('action.save')"
outline
@click="addEnvironment"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="hideModal"
/>
</span>
</template>
</HoppSmartModal>
</template>
<script lang="ts" setup>
import { Environment } from "@hoppscotch/data"
import { ref, watch } from "vue"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { GQLError } from "~/helpers/backend/GQLClient"
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import {
addEnvironmentVariable,
addGlobalEnvVariable,
} from "~/newstore/environments"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { updateTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment"
import { currentActiveTab } from "~/helpers/rest/tab"
const t = useI18n()
const toast = useToast()
const props = defineProps<{
show: boolean
position: { top: number; left: number }
name: string
value: string
replaceWithVariable: boolean
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const hideModal = () => {
emit("hide-modal")
}
watch(
() => props.show,
(newVal) => {
if (!newVal) {
scope.value = {
type: "global",
}
name.value = ""
replaceWithVariable.value = false
}
}
)
type Scope =
| {
type: "global"
}
| {
type: "my-environment"
environment: Environment
index: number
}
| {
type: "team-environment"
environment: TeamEnvironment
}
const scope = ref<Scope>({
type: "global",
})
const replaceWithVariable = ref(false)
const name = ref("")
const addEnvironment = async () => {
if (!name.value) {
toast.error(`${t("environment.invalid_name")}`)
return
}
if (scope.value.type === "global") {
addGlobalEnvVariable({
key: name.value,
value: props.value,
})
toast.success(`${t("environment.updated")}`)
} else if (scope.value.type === "my-environment") {
addEnvironmentVariable(scope.value.index, {
key: name.value,
value: props.value,
})
toast.success(`${t("environment.updated")}`)
} else {
const newVariables = [
...scope.value.environment.environment.variables,
{
key: name.value,
value: props.value,
},
]
await pipe(
updateTeamEnvironment(
JSON.stringify(newVariables),
scope.value.environment.id,
scope.value.environment.environment.name
),
TE.match(
(err: GQLError<string>) => {
console.error(err)
toast.error(`${getErrorMessage(err)}`)
},
() => {
hideModal()
toast.success(`${t("environment.updated")}`)
}
)
)()
}
if (replaceWithVariable.value) {
//replace the current tab endpoint with the variable name with << and >>
const variableName = `<<${name.value}>>`
//replace the currenttab endpoint containing the value in the text with variablename
currentActiveTab.value.document.request.endpoint =
currentActiveTab.value.document.request.endpoint.replace(
props.value,
variableName
)
}
hideModal()
}
const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") {
return t("error.network_error")
} else {
switch (err.error) {
case "team_environment/not_found":
return t("team_environment.not_found")
case "Forbidden resource":
return t("profile.no_permission")
default:
return t("error.something_went_wrong")
}
}
}
</script>

View File

@@ -190,6 +190,12 @@ const createEnvironmentGist = async () => {
)
toast.success(t("export.gist_created").toString())
platform.analytics?.logEvent({
type: "HOPP_EXPORT_ENVIRONMENT",
platform: "rest",
})
window.open(res.data.html_url)
} catch (e) {
toast.error(t("error.something_went_wrong").toString())
@@ -249,6 +255,13 @@ const openDialogChooseFileToImportFrom = () => {
const importToTeams = async (content: Environment[]) => {
loading.value = true
platform.analytics?.logEvent({
type: "HOPP_IMPORT_ENVIRONMENT",
platform: "rest",
workspaceType: "team",
})
for (const [i, env] of content.entries()) {
if (i === content.length - 1) {
await pipe(
@@ -301,6 +314,12 @@ const importFromJSON = () => {
return
}
platform.analytics?.logEvent({
type: "HOPP_IMPORT_ENVIRONMENT",
platform: "rest",
workspaceType: "personal",
})
const reader = new FileReader()
reader.onload = ({ target }) => {
@@ -352,6 +371,7 @@ const importFromPostman = ({
const environment: Environment = { name, variables: [] }
values.forEach(({ key, value }) => environment.variables.push({ key, value }))
const environments = [environment]
importFromHoppscotch(environments)
}

View File

@@ -8,7 +8,7 @@
<span
v-tippy="{ theme: 'tooltip' }"
:title="`${t('environment.select')}`"
class="bg-transparent border-b border-dividerLight select-wrapper"
class="bg-transparent select-wrapper"
>
<HoppButtonSecondary
:icon="IconLayers"
@@ -22,6 +22,7 @@
class="flex-1 !justify-start pr-8 rounded-none"
/>
</span>
<template #content="{ hide }">
<div
ref="tippyActions"
@@ -31,6 +32,7 @@
@keyup.escape="hide()"
>
<HoppSmartItem
v-if="!isScopeSelector"
:label="`${t('environment.no_environment')}`"
:info-icon="
selectedEnvironmentIndex.type === 'NO_ENV_SELECTED'
@@ -47,6 +49,21 @@
}
"
/>
<HoppSmartItem
v-else-if="isScopeSelector && modelValue"
:label="t('environment.global')"
:icon="IconGlobe"
:info-icon="modelValue.type === 'global' ? IconCheck : undefined"
:active-info-icon="modelValue.type === 'global'"
@click="
() => {
$emit('update:modelValue', {
type: 'global',
})
hide()
}
"
/>
<HoppSmartTabs
v-model="selectedEnvTab"
styles="sticky overflow-x-auto my-2 border border-divider rounded flex-shrink-0 z-10 top-0 bg-primary"
@@ -61,29 +78,25 @@
:key="`gen-${index}`"
:icon="IconLayers"
:label="gen.name"
:info-icon="index === selectedEnv.index ? IconCheck : undefined"
:active-info-icon="index === selectedEnv.index"
:info-icon="isEnvActive(index) ? IconCheck : undefined"
:active-info-icon="isEnvActive(index)"
@click="
() => {
selectedEnvironmentIndex = { type: 'MY_ENV', index: index }
handleEnvironmentChange(index, {
type: 'my-environment',
environment: gen,
})
hide()
}
"
/>
<div
<HoppSmartPlaceholder
v-if="myEnvironments.length === 0"
class="flex flex-col items-center justify-center text-secondaryLight"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
>
<img
:src="`/images/states/${colorMode.value}/blockchain.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-2"
:alt="`${t('empty.environments')}`"
/>
<span class="pb-2 text-center">
{{ t("empty.environments") }}
</span>
</div>
</HoppSmartPlaceholder>
</HoppSmartTab>
<HoppSmartTab
:id="'team-environments'"
@@ -103,36 +116,26 @@
:key="`gen-team-${index}`"
:icon="IconLayers"
:label="gen.environment.name"
:info-icon="
gen.id === selectedEnv.teamEnvID ? IconCheck : undefined
"
:active-info-icon="gen.id === selectedEnv.teamEnvID"
:info-icon="isEnvActive(gen.id) ? IconCheck : undefined"
:active-info-icon="isEnvActive(gen.id)"
@click="
() => {
selectedEnvironmentIndex = {
type: 'TEAM_ENV',
teamEnvID: gen.id,
teamID: gen.teamID,
environment: gen.environment,
}
handleEnvironmentChange(index, {
type: 'team-environment',
environment: gen,
})
hide()
}
"
/>
<div
<HoppSmartPlaceholder
v-if="teamEnvironmentList.length === 0"
class="flex flex-col items-center justify-center text-secondaryLight"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
>
<img
:src="`/images/states/${colorMode.value}/blockchain.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-2"
:alt="`${t('empty.environments')}`"
/>
<span class="pb-2 text-center">
{{ t("empty.environments") }}
</span>
</div>
</HoppSmartPlaceholder>
</div>
<div
v-if="!teamListLoading && teamAdapterError"
@@ -149,9 +152,10 @@
</template>
<script lang="ts" setup>
import { computed, ref, watch } from "vue"
import { computed, onMounted, ref, watch } from "vue"
import IconCheck from "~icons/lucide/check"
import IconLayers from "~icons/lucide/layers"
import IconGlobe from "~icons/lucide/globe"
import { TippyComponent } from "vue-tippy"
import { useI18n } from "~/composables/i18n"
import { GQLError } from "~/helpers/backend/GQLClient"
@@ -169,6 +173,31 @@ import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { useLocalState } from "~/newstore/localstate"
import { onLoggedIn } from "~/composables/auth"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import { Environment } from "@hoppscotch/data"
type Scope =
| {
type: "global"
}
| {
type: "my-environment"
environment: Environment
index: number
}
| {
type: "team-environment"
environment: TeamEnvironment
}
const props = defineProps<{
isScopeSelector?: boolean
modelValue?: Scope
}>()
const emit = defineEmits<{
(e: "update:modelValue", data: Scope): void
}>()
const breakpoints = useBreakpoints(breakpointsTailwind)
const mdAndLarger = breakpoints.greater("md")
@@ -183,6 +212,39 @@ const myEnvironments = useReadonlyStream(environments$, [])
const workspace = useReadonlyStream(workspaceStatus$, { type: "personal" })
// TeamList-Adapter
const teamListAdapter = new TeamListAdapter(true)
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
const teamListFetched = ref(false)
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
onLoggedIn(() => {
!teamListAdapter.isInitialized && teamListAdapter.initialize()
})
const switchToTeamWorkspace = (team: GetMyTeamsQuery["myTeams"][number]) => {
REMEMBERED_TEAM_ID.value = team.id
changeWorkspace({
teamID: team.id,
teamName: team.name,
type: "team",
})
}
watch(
() => myTeams.value,
(newTeams) => {
if (newTeams && !teamListFetched.value) {
teamListFetched.value = true
if (REMEMBERED_TEAM_ID.value) {
const team = newTeams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
if (team) switchToTeamWorkspace(team)
}
}
}
)
// TeamEnv List Adapter
const teamEnvListAdapter = new TeamEnvironmentAdapter(undefined)
const teamListLoading = useReadonlyStream(teamEnvListAdapter.loading$, false)
const teamAdapterError = useReadonlyStream(teamEnvListAdapter.error$, null)
@@ -217,63 +279,152 @@ watch(
}
)
// TeamList-Adapter
const teamListAdapter = new TeamListAdapter(true)
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
const teamListFetched = ref(false)
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
onLoggedIn(() => {
!teamListAdapter.isInitialized && teamListAdapter.initialize()
})
const switchToTeamWorkspace = (team: GetMyTeamsQuery["myTeams"][number]) => {
REMEMBERED_TEAM_ID.value = team.id
changeWorkspace({
teamID: team.id,
teamName: team.name,
type: "team",
})
}
watch(
() => myTeams.value,
(newTeams) => {
if (newTeams && !teamListFetched.value) {
teamListFetched.value = true
if (REMEMBERED_TEAM_ID.value) {
const team = newTeams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
if (team) switchToTeamWorkspace(team)
const handleEnvironmentChange = (
index: number,
env?:
| {
type: "my-environment"
environment: Environment
}
| {
type: "team-environment"
environment: TeamEnvironment
}
) => {
if (props.isScopeSelector && env) {
if (env.type === "my-environment") {
emit("update:modelValue", {
type: "my-environment",
environment: env.environment,
index,
})
} else if (env.type === "team-environment") {
emit("update:modelValue", {
type: "team-environment",
environment: env.environment,
})
}
} else {
if (env && env.type === "my-environment") {
selectedEnvironmentIndex.value = {
type: "MY_ENV",
index,
}
} else if (env && env.type === "team-environment") {
selectedEnvironmentIndex.value = {
type: "TEAM_ENV",
teamEnvID: env.environment.id,
teamID: env.environment.teamID,
environment: env.environment.environment,
}
}
}
)
}
const isEnvActive = (id: string | number) => {
if (props.isScopeSelector) {
if (props.modelValue?.type === "my-environment") {
return props.modelValue.index === id
} else if (props.modelValue?.type === "team-environment") {
return (
props.modelValue?.type === "team-environment" &&
props.modelValue.environment &&
props.modelValue.environment.id === id
)
}
} else {
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
return selectedEnv.value.index === id
} else {
return (
selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnv.value.teamEnvID === id
)
}
}
}
const selectedEnv = computed(() => {
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
return {
type: "MY_ENV",
index: selectedEnvironmentIndex.value.index,
name: myEnvironments.value[selectedEnvironmentIndex.value.index].name,
}
} else if (selectedEnvironmentIndex.value.type === "TEAM_ENV") {
const teamEnv = teamEnvironmentList.value.find(
(env) =>
env.id ===
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID)
)
if (teamEnv) {
if (props.isScopeSelector) {
if (props.modelValue?.type === "my-environment") {
return {
type: "MY_ENV",
index: props.modelValue.index,
name: props.modelValue.environment?.name,
}
} else if (props.modelValue?.type === "team-environment") {
return {
type: "TEAM_ENV",
name: teamEnv.environment.name,
teamEnvID: selectedEnvironmentIndex.value.teamEnvID,
name: props.modelValue.environment.environment.name,
teamEnvID: props.modelValue.environment.id,
}
} else {
return { type: "global", name: "Global" }
}
} else {
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
return {
type: "MY_ENV",
index: selectedEnvironmentIndex.value.index,
name: myEnvironments.value[selectedEnvironmentIndex.value.index].name,
}
} else if (selectedEnvironmentIndex.value.type === "TEAM_ENV") {
const teamEnv = teamEnvironmentList.value.find(
(env) =>
env.id ===
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID)
)
if (teamEnv) {
return {
type: "TEAM_ENV",
name: teamEnv.environment.name,
teamEnvID: selectedEnvironmentIndex.value.teamEnvID,
}
} else {
return { type: "NO_ENV_SELECTED" }
}
} else {
return { type: "NO_ENV_SELECTED" }
}
} else {
return { type: "NO_ENV_SELECTED" }
}
})
// Set the selected environment as initial scope value
onMounted(() => {
if (props.isScopeSelector) {
if (
selectedEnvironmentIndex.value.type === "MY_ENV" &&
selectedEnvironmentIndex.value.index !== undefined
) {
emit("update:modelValue", {
type: "my-environment",
environment: myEnvironments.value[selectedEnvironmentIndex.value.index],
index: selectedEnvironmentIndex.value.index,
})
} else if (
selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID &&
teamEnvironmentList.value &&
teamEnvironmentList.value.length > 0
) {
const teamEnv = teamEnvironmentList.value.find(
(env) =>
env.id ===
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID)
)
if (teamEnv) {
emit("update:modelValue", {
type: "team-environment",
environment: teamEnv,
})
}
} else {
emit("update:modelValue", {
type: "global",
})
}
}
})

View File

@@ -26,6 +26,13 @@
:editing-variable-name="editingVariableName"
@hide-modal="displayModalEdit(false)"
/>
<EnvironmentsAdd
:show="showModalNew"
:name="editingVariableName"
:value="editingVariableValue"
:position="position"
@hide-modal="displayModalNew(false)"
/>
</div>
</template>
@@ -161,10 +168,18 @@ watch(
}
)
const showModalNew = ref(false)
const showModalDetails = ref(false)
const action = ref<"new" | "edit">("edit")
const editingEnvironmentIndex = ref<"Global" | null>(null)
const editingVariableName = ref("")
const editingVariableValue = ref("")
const position = ref({ top: 0, left: 0 })
const displayModalNew = (shouldDisplay: boolean) => {
showModalNew.value = shouldDisplay
}
const displayModalEdit = (shouldDisplay: boolean) => {
action.value = "edit"
@@ -233,4 +248,10 @@ watch(
},
{ deep: true }
)
defineActionHandler("modals.environment.add", ({ envName, variableName }) => {
editingVariableName.value = envName
editingVariableValue.value = variableName
displayModalNew(true)
})
</script>

View File

@@ -7,22 +7,15 @@
>
<template #body>
<div class="flex flex-col">
<div class="relative flex">
<input
id="selectLabelEnvEdit"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
:disabled="editingEnvironmentIndex === 'Global'"
@keyup.enter="saveEnvironment"
/>
<label for="selectLabelEnvEdit">
{{ t("action.label") }}
</label>
</div>
<HoppSmartInput
v-model="name"
placeholder=" "
:label="t('action.label')"
input-styles="floating-input"
:disabled="editingEnvironmentIndex === 'Global'"
@submit="saveEnvironment"
/>
<div class="flex items-center justify-between flex-1">
<label for="variableList" class="p-4">
{{ t("environment.variable_list") }}
@@ -79,26 +72,19 @@
/>
</div>
</div>
<div
<HoppSmartPlaceholder
v-if="vars.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
>
<img
:src="`/images/states/${colorMode.value}/blockchain.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.environments')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.environments") }}
</span>
<HoppButtonSecondary
:label="`${t('add.new')}`"
filled
class="mb-4"
@click="addEnvironmentVariable"
/>
</div>
</HoppSmartPlaceholder>
</div>
</div>
</template>
@@ -148,6 +134,7 @@ import { useToast } from "@composables/toast"
import { useReadonlyStream } from "@composables/stream"
import { useColorMode } from "@composables/theming"
import { environmentsStore } from "~/newstore/environments"
import { platform } from "~/platform"
type EnvironmentVariable = {
id: number
@@ -311,6 +298,11 @@ const saveEnvironment = () => {
index: envList.value.length - 1,
})
toast.success(`${t("environment.created")}`)
platform.analytics?.logEvent({
type: "HOPP_CREATE_ENVIRONMENT",
workspaceType: "personal",
})
} else if (props.editingEnvironmentIndex === "Global") {
// Editing the Global environment
setGlobalEnvVariables(environmentUpdated.variables)

View File

@@ -32,19 +32,12 @@
:environment="environment"
@edit-environment="editEnvironment(index)"
/>
<div
<HoppSmartPlaceholder
v-if="environments.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
>
<img
:src="`/images/states/${colorMode.value}/blockchain.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.environments')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.environments") }}
</span>
<HoppButtonSecondary
:label="`${t('add.new')}`"
filled
@@ -52,7 +45,7 @@
class="mb-4"
@click="displayModalAdd(true)"
/>
</div>
</HoppSmartPlaceholder>
<EnvironmentsMyDetails
:show="showModalDetails"
:action="action"

View File

@@ -7,23 +7,15 @@
>
<template #body>
<div class="flex flex-col px-2">
<div class="relative flex">
<input
id="selectLabelEnvEdit"
v-model="name"
v-focus
class="input floating-input"
:class="isViewer && 'opacity-25'"
placeholder=""
type="text"
autocomplete="off"
:disabled="isViewer"
@keyup.enter="saveEnvironment"
/>
<label for="selectLabelEnvEdit">
{{ t("action.label") }}
</label>
</div>
<HoppSmartInput
v-model="name"
placeholder=" "
:input-styles="['floating-input', isViewer && 'opacity-25']"
:label="t('action.label')"
:disabled="isViewer"
@submit="saveEnvironment"
/>
<div class="flex items-center justify-between flex-1">
<label for="variableList" class="p-4">
{{ t("environment.variable_list") }}
@@ -83,19 +75,12 @@
/>
</div>
</div>
<div
<HoppSmartPlaceholder
v-if="vars.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
>
<img
:src="`/images/states/${colorMode.value}/blockchain.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.environments')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.environments") }}
</span>
<HoppButtonSecondary
v-if="isViewer"
disabled
@@ -110,7 +95,7 @@
class="mb-4"
@click="addEnvironmentVariable"
/>
</div>
</HoppSmartPlaceholder>
</div>
</div>
</template>
@@ -156,6 +141,7 @@ import IconTrash from "~icons/lucide/trash"
import IconTrash2 from "~icons/lucide/trash-2"
import IconDone from "~icons/lucide/check"
import IconPlus from "~icons/lucide/plus"
import { platform } from "~/platform"
type EnvironmentVariable = {
id: number
@@ -294,6 +280,11 @@ const saveEnvironment = async () => {
)
if (props.action === "new") {
platform.analytics?.logEvent({
type: "HOPP_CREATE_ENVIRONMENT",
workspaceType: "team",
})
await pipe(
createTeamEnvironment(
JSON.stringify(filterdVariables),

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